fix(ticket): guard stale responses and stale hours
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,20 +75,32 @@ 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 {
|
||||||
setLoading(false)
|
if (loadSeqRef.current === seq) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [open, ticketId])
|
}, [open, ticketId])
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +169,21 @@ 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 {
|
||||||
setLoading(false)
|
if (loadSeqRef.current === seq) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [assigneeId, keyword, quickView, staleHours, tagId])
|
}, [assigneeId, keyword, quickView, staleHours, tagId])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user