fix(ticket): guard stale responses and stale hours

This commit is contained in:
mlogclub
2026-05-02 20:20:30 +08:00
parent 8a8b92a307
commit 8102f89308
5 changed files with 61 additions and 21 deletions
@@ -2,12 +2,10 @@ package dashboard
import ( import (
"strings" "strings"
"time"
"cs-agent/internal/builders" "cs-agent/internal/builders"
"cs-agent/internal/pkg/constants" "cs-agent/internal/pkg/constants"
"cs-agent/internal/pkg/dto/request" "cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/services" "cs-agent/internal/services"
"github.com/kataras/iris/v12" "github.com/kataras/iris/v12"
@@ -46,9 +44,7 @@ func (c *TicketController) AnyList() *web.JsonResult {
cnd.Eq("current_assignee_id", 0) cnd.Eq("current_assignee_id", 0)
} }
if staleHours, _ := params.GetInt(c.Ctx, "staleHours"); staleHours > 0 { if staleHours, _ := params.GetInt(c.Ctx, "staleHours"); staleHours > 0 {
cnd. services.TicketService.ApplyStaleFilter(cnd, staleHours)
NotEq("status", enums.TicketStatusDone).
Where("updated_at < ?", time.Now().Add(-time.Duration(staleHours)*time.Hour))
} }
aggregate, err := services.TicketService.FindPageAggregateByCnd(cnd, operator.UserID) aggregate, err := services.TicketService.FindPageAggregateByCnd(cnd, operator.UserID)
if err != nil { if err != nil {
+22 -7
View File
@@ -55,6 +55,15 @@ type TicketListAggregate struct {
type ticketService struct { type ticketService struct {
} }
func normalizeTicketStaleHours(staleHours int) int {
switch staleHours {
case 24, 48, 168:
return staleHours
default:
return 24
}
}
func (s *ticketService) Get(id int64) *models.Ticket { func (s *ticketService) Get(id int64) *models.Ticket {
return repositories.TicketRepository.Get(sqls.DB(), id) return repositories.TicketRepository.Get(sqls.DB(), id)
} }
@@ -84,6 +93,16 @@ func (s *ticketService) FindPageAggregateByCnd(cnd *sqls.Cnd, _ int64) (*TicketL
return s.buildTicketListAggregate(sqls.DB(), list, paging), nil return s.buildTicketListAggregate(sqls.DB(), list, paging), nil
} }
func (s *ticketService) ApplyStaleFilter(cnd *sqls.Cnd, staleHours int) *sqls.Cnd {
if cnd == nil {
cnd = sqls.NewCnd()
}
staleHour := normalizeTicketStaleHours(staleHours)
return cnd.
NotEq("status", enums.TicketStatusDone).
Where("updated_at < ?", time.Now().Add(-time.Duration(staleHour)*time.Hour))
}
func (s *ticketService) Count(cnd *sqls.Cnd) int64 { func (s *ticketService) Count(cnd *sqls.Cnd) int64 {
return repositories.TicketRepository.Count(sqls.DB(), cnd) return repositories.TicketRepository.Count(sqls.DB(), cnd)
} }
@@ -445,8 +464,8 @@ func (s *ticketService) GetDetail(id int64) (*TicketDetailAggregate, error) {
} }
func (s *ticketService) GetSummary(operator *dto.AuthPrincipal, staleHours ...int) *TicketSummaryAggregate { func (s *ticketService) GetSummary(operator *dto.AuthPrincipal, staleHours ...int) *TicketSummaryAggregate {
staleHour := 24 staleHour := 0
if len(staleHours) > 0 && staleHours[0] > 0 { if len(staleHours) > 0 {
staleHour = staleHours[0] staleHour = staleHours[0]
} }
summary := &TicketSummaryAggregate{ summary := &TicketSummaryAggregate{
@@ -455,11 +474,7 @@ func (s *ticketService) GetSummary(operator *dto.AuthPrincipal, staleHours ...in
InProgress: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusInProgress)), InProgress: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusInProgress)),
Done: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusDone)), Done: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusDone)),
Unassigned: s.Count(sqls.NewCnd().Eq("current_assignee_id", 0)), Unassigned: s.Count(sqls.NewCnd().Eq("current_assignee_id", 0)),
Stale: s.Count( Stale: s.Count(s.ApplyStaleFilter(sqls.NewCnd(), staleHour)),
sqls.NewCnd().
NotEq("status", enums.TicketStatusDone).
Where("updated_at < ?", time.Now().Add(-time.Duration(staleHour)*time.Hour)),
),
} }
if operator != nil { if operator != nil {
summary.Mine = s.Count(sqls.NewCnd().Eq("current_assignee_id", operator.UserID)) summary.Mine = s.Count(sqls.NewCnd().Eq("current_assignee_id", operator.UserID))
+11 -5
View File
@@ -259,7 +259,7 @@ func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) {
if _, err := services.TicketService.CreateTicket(createTestTicketRequest("unassigned ticket"), operator); err != nil { if _, err := services.TicketService.CreateTicket(createTestTicketRequest("unassigned ticket"), operator); err != nil {
t.Fatalf("CreateTicket() unassigned error = %v", err) t.Fatalf("CreateTicket() unassigned error = %v", err)
} }
staleUpdatedAt := time.Now().Add(-48 * time.Hour) staleUpdatedAt := time.Now().Add(-36 * time.Hour)
if err := repositories.TicketRepository.Updates(sqls.DB(), mine.ID, map[string]any{ if err := repositories.TicketRepository.Updates(sqls.DB(), mine.ID, map[string]any{
"updated_at": staleUpdatedAt, "updated_at": staleUpdatedAt,
}); err != nil { }); err != nil {
@@ -282,6 +282,15 @@ func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) {
if summary.Stale != 1 { if summary.Stale != 1 {
t.Fatalf("expected stale count 1, got %d", summary.Stale) t.Fatalf("expected stale count 1, got %d", summary.Stale)
} }
summary48 := services.TicketService.GetSummary(operator, 48)
if summary48.Stale != 0 {
t.Fatalf("expected stale count 0 for 48 hour threshold, got %d", summary48.Stale)
}
summaryInvalid := services.TicketService.GetSummary(operator, 1<<30)
if summaryInvalid.Stale != 1 {
t.Fatalf("expected invalid stale threshold to use 24 hours, got %d", summaryInvalid.Stale)
}
} }
func TestTicketServiceFindPageAggregateFiltersStaleTickets(t *testing.T) { func TestTicketServiceFindPageAggregateFiltersStaleTickets(t *testing.T) {
@@ -316,10 +325,7 @@ func TestTicketServiceFindPageAggregateFiltersStaleTickets(t *testing.T) {
} }
aggregate, err := services.TicketService.FindPageAggregateByCnd( aggregate, err := services.TicketService.FindPageAggregateByCnd(
sqls.NewCnd(). services.TicketService.ApplyStaleFilter(sqls.NewCnd(), 24).Page(1, 10),
NotEq("status", enums.TicketStatusDone).
Where("updated_at < ?", time.Now().Add(-24*time.Hour)).
Page(1, 10),
operator.UserID, operator.UserID,
) )
if err != nil { if err != nil {
@@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { MessageSquareTextIcon, RefreshCcwIcon, SendIcon, UserRoundIcon } from "lucide-react" import { MessageSquareTextIcon, RefreshCcwIcon, SendIcon, UserRoundIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -75,21 +75,33 @@ export function TicketDetailDialog({
const [assignOpen, setAssignOpen] = useState(false) const [assignOpen, setAssignOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [editSaving, setEditSaving] = useState(false) const [editSaving, setEditSaving] = useState(false)
const loadSeqRef = useRef(0)
const loadDetail = useCallback(async () => { const loadDetail = useCallback(async () => {
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
if (!open || !ticketId) { if (!open || !ticketId) {
setDetail(null) setDetail(null)
setLoading(false)
return return
} }
setLoading(true) setLoading(true)
try { try {
const data = await fetchTicketDetail(ticketId) const data = await fetchTicketDetail(ticketId)
if (loadSeqRef.current !== seq) {
return
}
setDetail(data) setDetail(data)
} catch (error) { } catch (error) {
if (loadSeqRef.current !== seq) {
return
}
toast.error(error instanceof Error ? error.message : "加载工单详情失败") toast.error(error instanceof Error ? error.message : "加载工单详情失败")
} finally { } finally {
if (loadSeqRef.current === seq) {
setLoading(false) setLoading(false)
} }
}
}, [open, ticketId]) }, [open, ticketId])
useEffect(() => { useEffect(() => {
+12 -1
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { PlusIcon, RefreshCcwIcon, SearchXIcon } from "lucide-react" import { PlusIcon, RefreshCcwIcon, SearchXIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { OptionCombobox, type ComboboxOption } from "@/components/option-combobox" import { OptionCombobox, type ComboboxOption } from "@/components/option-combobox"
@@ -119,6 +119,7 @@ export default function TicketsPage() {
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [savingCreate, setSavingCreate] = useState(false) const [savingCreate, setSavingCreate] = useState(false)
const loadSeqRef = useRef(0)
const quickViews = useMemo( const quickViews = useMemo(
() => () =>
@@ -138,6 +139,8 @@ export default function TicketsPage() {
const allPageSelected = tickets.length > 0 && tickets.every((ticket) => selectedTicketIds.has(ticket.id)) const allPageSelected = tickets.length > 0 && tickets.every((ticket) => selectedTicketIds.has(ticket.id))
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
setLoading(true) setLoading(true)
try { try {
const staleThreshold = Number(staleHours) const staleThreshold = Number(staleHours)
@@ -166,14 +169,22 @@ export default function TicketsPage() {
fetchTickets(query), fetchTickets(query),
fetchTicketSummary({ staleHours: staleThreshold }), fetchTicketSummary({ staleHours: staleThreshold }),
]) ])
if (loadSeqRef.current !== seq) {
return
}
setTickets(Array.isArray(ticketData.results) ? ticketData.results : []) setTickets(Array.isArray(ticketData.results) ? ticketData.results : [])
setSummary(summaryData ?? emptySummary) setSummary(summaryData ?? emptySummary)
setSelectedTicketIds(new Set()) setSelectedTicketIds(new Set())
} catch (error) { } catch (error) {
if (loadSeqRef.current !== seq) {
return
}
toast.error(error instanceof Error ? error.message : "加载工单失败") toast.error(error instanceof Error ? error.message : "加载工单失败")
} finally { } finally {
if (loadSeqRef.current === seq) {
setLoading(false) setLoading(false)
} }
}
}, [assigneeId, keyword, quickView, staleHours, tagId]) }, [assigneeId, keyword, quickView, staleHours, tagId])
useEffect(() => { useEffect(() => {