From 8102f893085a55170f32eb11982cef4fb27bed52 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 2 May 2026 20:20:30 +0800 Subject: [PATCH] fix(ticket): guard stale responses and stale hours --- .../dashboard/ticket_controller.go | 6 +--- internal/services/ticket_service.go | 29 ++++++++++++++----- internal/services/ticket_service_test.go | 16 ++++++---- .../_components/ticket-detail-dialog.tsx | 16 ++++++++-- web/app/dashboard/tickets/page.tsx | 15 ++++++++-- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/internal/controllers/dashboard/ticket_controller.go b/internal/controllers/dashboard/ticket_controller.go index 1804719..3f4ce59 100644 --- a/internal/controllers/dashboard/ticket_controller.go +++ b/internal/controllers/dashboard/ticket_controller.go @@ -2,12 +2,10 @@ package dashboard import ( "strings" - "time" "cs-agent/internal/builders" "cs-agent/internal/pkg/constants" "cs-agent/internal/pkg/dto/request" - "cs-agent/internal/pkg/enums" "cs-agent/internal/services" "github.com/kataras/iris/v12" @@ -46,9 +44,7 @@ func (c *TicketController) AnyList() *web.JsonResult { cnd.Eq("current_assignee_id", 0) } if staleHours, _ := params.GetInt(c.Ctx, "staleHours"); staleHours > 0 { - cnd. - NotEq("status", enums.TicketStatusDone). - Where("updated_at < ?", time.Now().Add(-time.Duration(staleHours)*time.Hour)) + services.TicketService.ApplyStaleFilter(cnd, staleHours) } aggregate, err := services.TicketService.FindPageAggregateByCnd(cnd, operator.UserID) if err != nil { diff --git a/internal/services/ticket_service.go b/internal/services/ticket_service.go index bd740f9..0572afa 100644 --- a/internal/services/ticket_service.go +++ b/internal/services/ticket_service.go @@ -55,6 +55,15 @@ type TicketListAggregate 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 { 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 } +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 { 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 { - staleHour := 24 - if len(staleHours) > 0 && staleHours[0] > 0 { + staleHour := 0 + if len(staleHours) > 0 { staleHour = staleHours[0] } summary := &TicketSummaryAggregate{ @@ -455,11 +474,7 @@ func (s *ticketService) GetSummary(operator *dto.AuthPrincipal, staleHours ...in InProgress: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusInProgress)), Done: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusDone)), Unassigned: s.Count(sqls.NewCnd().Eq("current_assignee_id", 0)), - Stale: s.Count( - sqls.NewCnd(). - NotEq("status", enums.TicketStatusDone). - Where("updated_at < ?", time.Now().Add(-time.Duration(staleHour)*time.Hour)), - ), + Stale: s.Count(s.ApplyStaleFilter(sqls.NewCnd(), staleHour)), } if operator != nil { summary.Mine = s.Count(sqls.NewCnd().Eq("current_assignee_id", operator.UserID)) diff --git a/internal/services/ticket_service_test.go b/internal/services/ticket_service_test.go index 9a7f0d3..b92a792 100644 --- a/internal/services/ticket_service_test.go +++ b/internal/services/ticket_service_test.go @@ -259,7 +259,7 @@ func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) { if _, err := services.TicketService.CreateTicket(createTestTicketRequest("unassigned ticket"), operator); err != nil { 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{ "updated_at": staleUpdatedAt, }); err != nil { @@ -282,6 +282,15 @@ func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) { if summary.Stale != 1 { 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) { @@ -316,10 +325,7 @@ func TestTicketServiceFindPageAggregateFiltersStaleTickets(t *testing.T) { } aggregate, err := services.TicketService.FindPageAggregateByCnd( - sqls.NewCnd(). - NotEq("status", enums.TicketStatusDone). - Where("updated_at < ?", time.Now().Add(-24*time.Hour)). - Page(1, 10), + services.TicketService.ApplyStaleFilter(sqls.NewCnd(), 24).Page(1, 10), operator.UserID, ) if err != nil { diff --git a/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx b/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx index 7024577..0def306 100644 --- a/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx +++ b/web/app/dashboard/tickets/_components/ticket-detail-dialog.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { MessageSquareTextIcon, RefreshCcwIcon, SendIcon, UserRoundIcon } from "lucide-react" import { toast } from "sonner" @@ -75,20 +75,32 @@ export function TicketDetailDialog({ const [assignOpen, setAssignOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) const [editSaving, setEditSaving] = useState(false) + const loadSeqRef = useRef(0) const loadDetail = useCallback(async () => { + const seq = loadSeqRef.current + 1 + loadSeqRef.current = seq if (!open || !ticketId) { setDetail(null) + setLoading(false) return } setLoading(true) try { const data = await fetchTicketDetail(ticketId) + if (loadSeqRef.current !== seq) { + return + } setDetail(data) } catch (error) { + if (loadSeqRef.current !== seq) { + return + } toast.error(error instanceof Error ? error.message : "加载工单详情失败") } finally { - setLoading(false) + if (loadSeqRef.current === seq) { + setLoading(false) + } } }, [open, ticketId]) diff --git a/web/app/dashboard/tickets/page.tsx b/web/app/dashboard/tickets/page.tsx index 12ce243..84a42ff 100644 --- a/web/app/dashboard/tickets/page.tsx +++ b/web/app/dashboard/tickets/page.tsx @@ -1,7 +1,7 @@ "use client" 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 { OptionCombobox, type ComboboxOption } from "@/components/option-combobox" @@ -119,6 +119,7 @@ export default function TicketsPage() { const [detailOpen, setDetailOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [savingCreate, setSavingCreate] = useState(false) + const loadSeqRef = useRef(0) const quickViews = useMemo( () => @@ -138,6 +139,8 @@ export default function TicketsPage() { const allPageSelected = tickets.length > 0 && tickets.every((ticket) => selectedTicketIds.has(ticket.id)) const loadData = useCallback(async () => { + const seq = loadSeqRef.current + 1 + loadSeqRef.current = seq setLoading(true) try { const staleThreshold = Number(staleHours) @@ -166,13 +169,21 @@ export default function TicketsPage() { fetchTickets(query), fetchTicketSummary({ staleHours: staleThreshold }), ]) + if (loadSeqRef.current !== seq) { + return + } setTickets(Array.isArray(ticketData.results) ? ticketData.results : []) setSummary(summaryData ?? emptySummary) setSelectedTicketIds(new Set()) } catch (error) { + if (loadSeqRef.current !== seq) { + return + } toast.error(error instanceof Error ? error.message : "加载工单失败") } finally { - setLoading(false) + if (loadSeqRef.current === seq) { + setLoading(false) + } } }, [assigneeId, keyword, quickView, staleHours, tagId])