diff --git a/api/admin/admin.go b/api/admin/admin.go index 7d9091d..fdec39b 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -258,6 +258,7 @@ type IAdminV1 interface { AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error) AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error) AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error) + AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) TaskList(ctx context.Context, req *v1.TaskListReq) (res *v1.TaskListRes, err error) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (res *v1.TaskDetailRes, err error) TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (res *v1.TaskCancelRes, err error) diff --git a/api/admin/v1/support_pending.go b/api/admin/v1/support_pending.go new file mode 100644 index 0000000..7d483b7 --- /dev/null +++ b/api/admin/v1/support_pending.go @@ -0,0 +1,24 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// === 顶栏铃铛(客服支持待办聚合) === + +// AdminSupportPendingSummaryReq 顶栏铃铛的待办计数。 +// 口径与工作台刻意不同:工作台只报「超 SLA / 未分配」等异常项避免永久红点; +// 铃铛报全量待处理 —— 管理员处理一条少一条,归零即「没有客户在等回复」, +// 是运营收件箱语义而非消息通知语义(无已读概念,不落消息表)。 +type AdminSupportPendingSummaryReq struct { + g.Meta `path:"/support/pending-summary" method:"get" mime:"json" tags:"管理后台-待办提醒" summary:"顶栏铃铛待办计数"` +} + +type AdminSupportPendingSummaryRes struct { + Tickets int `json:"tickets" dc:"待处理工单数(pending + reopened)"` + TicketsOverdue int `json:"tickets_overdue" dc:"其中超 SLA 未首次响应的工单数"` + Feedbacks int `json:"feedbacks" dc:"待处理反馈数"` + FeedbacksWait string `json:"feedbacks_wait" dc:"最早一条待处理反馈已等待时长(如「3 天」;无待处理时为空)"` + Total int `json:"total" dc:"tickets + feedbacks,铃铛角标总数"` + GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"` +} diff --git a/internal/controller/admin/admin_v1_support_pending.go b/internal/controller/admin/admin_v1_support_pending.go new file mode 100644 index 0000000..cd16028 --- /dev/null +++ b/internal/controller/admin/admin_v1_support_pending.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) { + return service.Admin().GetSupportPendingSummary(ctx, req) +} diff --git a/internal/logic/admin/support_pending.go b/internal/logic/admin/support_pending.go new file mode 100644 index 0000000..8f12224 --- /dev/null +++ b/internal/logic/admin/support_pending.go @@ -0,0 +1,103 @@ +package admin + +import ( + "context" + "encoding/json" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "github.com/qianfree/team-api/api/admin/v1" +) + +// ============================================================ +// 顶栏铃铛:客服支持待办聚合 +// +// 与工作台(workbench.go)的分工:工作台回答「哪些事异常了」, +// 刻意不报工单总数避免永久红点;铃铛回答「有多少客户在等回复」, +// 报全量 pending —— 处理完自动归零,不需要任何人点「完成」。 +// 待办不落库、实时派生,整份结果进 Redis 缓存 30s(与工作台同模式), +// 菜单红点轮询与铃铛各自独立缓存键,互不挤兑。 +// ============================================================ + +const ( + supportPendingCacheKey = "admin:support_pending:v1" + supportPendingCacheTTL = 30 * time.Second +) + +// supportPendingCollected 一轮聚合的完整结果(进缓存的就是它)。 +type supportPendingCollected struct { + Tickets int `json:"tickets"` + TicketsOverdue int `json:"tickets_overdue"` + Feedbacks int `json:"feedbacks"` + FeedbacksWait string `json:"feedbacks_wait"` + GeneratedAt string `json:"generated_at"` +} + +// GetSupportPendingSummary 顶栏铃铛待办计数。 +func (s *sAdmin) GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error) { + col := s.collectSupportPendingCached(ctx) + return &v1.AdminSupportPendingSummaryRes{ + Tickets: col.Tickets, + TicketsOverdue: col.TicketsOverdue, + Feedbacks: col.Feedbacks, + FeedbacksWait: col.FeedbacksWait, + Total: col.Tickets + col.Feedbacks, + GeneratedAt: col.GeneratedAt, + }, nil +} + +// collectSupportPendingCached 读缓存,未命中则重新聚合。 +// 缓存读写失败一律降级为直接查询 —— 铃铛不能因为 Redis 抖动就消失。 +func (s *sAdmin) collectSupportPendingCached(ctx context.Context) *supportPendingCollected { + if v, err := g.Redis().Get(ctx, supportPendingCacheKey); err == nil && !v.IsEmpty() { + var col supportPendingCollected + if json.Unmarshal(v.Bytes(), &col) == nil { + return &col + } + } + + col := s.collectSupportPending(ctx) + + if b, err := json.Marshal(col); err == nil { + if _, err := g.Redis().Do(ctx, "SET", supportPendingCacheKey, b, "EX", int(supportPendingCacheTTL.Seconds())); err != nil { + g.Log().Warningf(ctx, "support pending: 写缓存失败: %v", err) + } + } + return col +} + +// collectSupportPending 两个独立查询,任一失败只丢自己那部分(记 warning), +// 不拖垮整个铃铛 —— 半个铃铛也好过没有铃铛。 +func (s *sAdmin) collectSupportPending(ctx context.Context) *supportPendingCollected { + col := &supportPendingCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)} + + // 工单:pending + reopened 全量。超 SLA 口径与工作台 collector 一致: + // 仅 pending 计超时(reopened 说明已有人处理过,不算「未首响」)。 + rows, err := g.DB().Ctx(ctx).Query(ctx, + `SELECT COUNT(*) AS cnt, + COUNT(*) FILTER (WHERE status = 'pending' AND created_at <= ?) AS overdue + FROM spt_tickets + WHERE status IN ('pending', 'reopened')`, gtime.Now().Add(-wbTicketSLAHours*time.Hour)) + if err != nil { + g.Log().Warningf(ctx, "support pending: 查询待处理工单失败: %v", err) + } else if len(rows) > 0 { + col.Tickets = rows[0]["cnt"].Int() + col.TicketsOverdue = rows[0]["overdue"].Int() + } + + // 反馈:pending 全量 + 最早一条的等待时长(反馈无 SLA 概念,只展示积压程度)。 + fbRows, err := g.DB().Ctx(ctx).Query(ctx, + `SELECT COUNT(*) AS cnt, MIN(created_at) AS oldest + FROM spt_feedbacks WHERE status = 'pending'`) + if err != nil { + g.Log().Warningf(ctx, "support pending: 查询待处理反馈失败: %v", err) + } else if len(fbRows) > 0 { + col.Feedbacks = fbRows[0]["cnt"].Int() + if oldest := fbRows[0]["oldest"].GTime(); oldest != nil { + col.FeedbacksWait = humanizeSince(oldest) + } + } + return col +} diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 42c6184..0025e71 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -152,6 +152,8 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/tickets/", suffix: "/assign", perm: "support:edit"}, {method: "POST", prefix: "/api/admin/tickets/", suffix: "/reply", perm: "support:reply"}, {method: "PUT", prefix: "/api/admin/tickets/", suffix: "/status", perm: "support:edit"}, + // 顶栏铃铛待办计数(工单 + 反馈聚合,权限收敛在 support:view) + {method: "GET", path: "/api/admin/support/pending-summary", perm: "support:view"}, // ── help center 帮助中心 ── {method: "GET", path: "/api/admin/help-categories", perm: "support:view"}, diff --git a/internal/service/admin.go b/internal/service/admin.go index 9cda7d6..26ce8f7 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -527,6 +527,8 @@ type ( // 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试 // 三次也不会变好),不该让管理员多等十几秒的退避。 TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error) + // GetSupportPendingSummary 顶栏铃铛待办计数。 + GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error) // TaskList 大模型异步任务列表 TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error) // TaskDetail 大模型异步任务详情 diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index cb9278e..53c138e 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -1,5 +1,5 @@