From 211148a0afa3860f959a0d9f87ee8e77eb9ff727 Mon Sep 17 00:00:00 2001 From: qian Date: Mon, 31 Aug 2026 23:11:10 +0800 Subject: [PATCH] =?UTF-8?q?improve(workbench):=E4=BC=98=E5=8C=96=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/workbench.go | 9 +- internal/dispatchadapter/maintenance.go | 6 +- internal/logic/admin/workbench.go | 34 ++---- internal/logic/admin/workbench_collectors.go | 108 +++++++----------- internal/logic/common/cron.go | 15 +++ internal/logic/common/cron_test.go | 27 +++++ web/admin/src/views/AdminLayout.vue | 11 +- web/admin/src/views/WorkbenchPage.vue | 32 +++++- .../src/views/system/settings/EmailTab.vue | 2 +- 9 files changed, 139 insertions(+), 105 deletions(-) diff --git a/api/admin/v1/workbench.go b/api/admin/v1/workbench.go index 5755530..9b84109 100644 --- a/api/admin/v1/workbench.go +++ b/api/admin/v1/workbench.go @@ -97,13 +97,10 @@ type AdminWorkbenchSummaryRes struct { // AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。 // 与 summary 共用同一份缓存,不会额外压库。 type AdminWorkbenchBadgeReq struct { - g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"菜单红点计数"` + g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"工作台菜单角标计数"` } type AdminWorkbenchBadgeRes struct { - Total int `json:"total" dc:"待办总数"` - Urgent int `json:"urgent" dc:"P0 数量"` - Domains map[string]int `json:"domains" dc:"各域待办数,键为域标识"` - // Menus 前端菜单路由名 → 待办数,直接挂角标,前端不需要自己映射域到菜单。 - Menus map[string]int `json:"menus" dc:"菜单路由名 → 待办数"` + Total int `json:"total" dc:"待办总数,仅挂在工作台菜单角标"` + Urgent int `json:"urgent" dc:"P0 数量,控制角标转红"` } diff --git a/internal/dispatchadapter/maintenance.go b/internal/dispatchadapter/maintenance.go index d4a2fae..2b0441b 100644 --- a/internal/dispatchadapter/maintenance.go +++ b/internal/dispatchadapter/maintenance.go @@ -148,13 +148,13 @@ func runMaintenance(ctx context.Context) { // 覆盖摘要:目录只装载 active × 有启用能力行的渠道,未覆盖渠道的健康分不会落盘 // (探测却正常的渠道长期停在历史分数时,先看这里是否被跳过) - g.Log().Infof(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered) + g.Log().Debugf(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered) if len(skippedDegraded) > 0 { g.Log().Warningf(ctx, "[ChannelHealth] 本轮 %d 个渠道因 Redis 健康读取失败跳过落盘(保留库中旧值): %v", len(skippedDegraded), skippedDegraded) } if len(skippedNoData) > 0 { - g.Log().Infof(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v", + g.Log().Debugf(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v", len(skippedNoData), skippedNoData) } } @@ -258,7 +258,7 @@ func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alph // 分数有实际变化才记 Info;稳态渠道降为 Debug(避免每 5 分钟 × 渠道数的固定噪音)。 // 两种情况都带各模型 succ 明细:排查"健康分不符合预期"时可直接定位是哪个模型拉低了均值。 if math.Abs(healthScore-prevScore) >= 0.05 { - g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s", + g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s", channelID, prevScore, healthScore, succ, modelDetail) } else { g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康快照无变化: %.1f | avg_succ %.4f | 模型: %s", diff --git a/internal/logic/admin/workbench.go b/internal/logic/admin/workbench.go index 32ff739..6161299 100644 --- a/internal/logic/admin/workbench.go +++ b/internal/logic/admin/workbench.go @@ -39,11 +39,14 @@ const ( wbOrderUnfulfilledMin = 15 // 已支付超过 15 分钟仍未履约视为卡单 wbOAuthExpiryHours = 24 // OAuth 令牌 24 小时内过期即预警 wbHealthDropPoints = 30 // 健康分 1 小时内跌幅超过 30 分视为骤降 - wbCostSpikeMultiple = 8 // 单笔消费超过近期均值 8 倍视为异常 wbEmailFailThreshold = 5 // 1 小时内邮件失败超过 5 封视为链路异常 wbPlanExpiringDays = 7 // 套餐 7 天内到期 wbTicketSLAHours = 2 // 工单首次响应 SLA wbWalletDriftRatio = 0.02 // Redis↔DB 余额偏差超过 2% 且绝对额可观时告警 + + // wbCronStallFallbackInterval 停摆判定的调度周期兜底值:任务不在注册表中 + // (改名/下线后的遗留行)或 cron 表达式解析失败时按「日任务」保守处理。 + wbCronStallFallbackInterval = 24 * time.Hour ) // wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。 @@ -63,14 +66,6 @@ type wbCollected struct { GeneratedAt string `json:"generated_at"` } -// 域 → 前端菜单路由名,供菜单红点直接挂角标。 -var wbDomainMenus = map[string][]string{ - v1.WorkbenchDomainAvailability: {"AdminChannels"}, - v1.WorkbenchDomainMoney: {"AdminOrders", "AdminTransactions"}, - v1.WorkbenchDomainCustomer: {"AdminTickets", "AdminFeedback"}, - v1.WorkbenchDomainSystem: {"AdminAlertEvents", "AdminErrorLogs", "AdminCronJobs"}, -} - // ============================================================ // 对外接口 // ============================================================ @@ -126,30 +121,21 @@ func (s *sAdmin) GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSu return res, nil } -// GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。 +// GetWorkbenchBadges 工作台菜单角标计数(与 summary 共用缓存,不额外压库)。 +// +// 角标只挂工作台菜单一项:待办的排查线索只存在于工作台的描述文案里, +// 业务菜单里没有对应的定位入口,往各业务菜单挂数字只会带来 +// 「进去了却找不到问题」的困惑(红点引路却无路可走)。 func (s *sAdmin) GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error) { col := s.collectWorkbenchCached(ctx) items := s.filterWorkbenchItems(ctx, col.Items) - res := &v1.AdminWorkbenchBadgeRes{ - Domains: map[string]int{}, - Menus: map[string]int{}, - } + res := &v1.AdminWorkbenchBadgeRes{} for _, it := range items { res.Total++ if it.Severity == v1.WorkbenchSeverityP0 { res.Urgent++ } - res.Domains[it.Domain]++ - } - for domain, menus := range wbDomainMenus { - n := res.Domains[domain] - if n == 0 { - continue - } - for _, m := range menus { - res.Menus[m] += n - } } return res, nil } diff --git a/internal/logic/admin/workbench_collectors.go b/internal/logic/admin/workbench_collectors.go index 187489c..ca7e166 100644 --- a/internal/logic/admin/workbench_collectors.go +++ b/internal/logic/admin/workbench_collectors.go @@ -12,6 +12,7 @@ import ( v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dispatchadapter" "github.com/qianfree/team-api/internal/logic/billing" + "github.com/qianfree/team-api/internal/logic/common" ) // ============================================================ @@ -235,7 +236,6 @@ func (s *sAdmin) wbCollectMoney(ctx context.Context) []wbItem { items = append(items, s.wbWalletDrift(ctx)...) items = append(items, s.wbFrozenStale(ctx)...) items = append(items, s.wbOrderUnfulfilled(ctx)...) - items = append(items, s.wbCostSpike(ctx)...) items = append(items, s.wbLowBalanceTenants(ctx)...) return items } @@ -383,58 +383,6 @@ func (s *sAdmin) wbOrderUnfulfilled(ctx context.Context) []wbItem { return items } -// wbCostSpike 单笔消费异常。 -// 用途有三:防刷、防客户被恶意调用、防定价配置写错一位(乘数多个 0 的杀伤力最大)。 -// 判据是「相对该租户自身近 7 天均值的倍数」,而非全局绝对值 —— 大客户的正常单笔 -// 对小客户就是天价,全局阈值必然两头不讨好。 -func (s *sAdmin) wbCostSpike(ctx context.Context) []wbItem { - rows, err := g.DB().Ctx(ctx).Query(ctx, - `WITH recent AS ( - SELECT tenant_id, id, total_cost, model_name, created_at - FROM bil_usage_logs - WHERE created_at >= NOW() - INTERVAL '2 hours' AND total_cost > 0 - ), baseline AS ( - SELECT tenant_id, AVG(total_cost) AS avg_cost - FROM bil_usage_logs - WHERE created_at >= NOW() - INTERVAL '7 days' AND total_cost > 0 - GROUP BY tenant_id - HAVING COUNT(*) >= 50 - ) - SELECT r.id, r.tenant_id, r.total_cost, r.model_name, r.created_at, - b.avg_cost, t.name AS tenant_name - FROM recent r - JOIN baseline b ON b.tenant_id = r.tenant_id - LEFT JOIN tnt_tenants t ON t.id = r.tenant_id - WHERE r.total_cost >= b.avg_cost * ? - ORDER BY r.total_cost DESC - LIMIT 5`, wbCostSpikeMultiple) - if err != nil { - g.Log().Warningf(ctx, "workbench: 查询消费尖刺失败: %v", err) - return nil - } - - items := make([]wbItem, 0, len(rows)) - for _, r := range rows { - cost := r["total_cost"].Float64() - avg := r["avg_cost"].Float64() - mult := 0.0 - if avg > 0 { - mult = cost / avg - } - items = append(items, wbNew( - fmt.Sprintf("usage_cost_spike:%d", r["id"].Int64()), - v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "billing:view", - fmt.Sprintf("租户「%s」单笔消费异常", r["tenant_name"].String()), - fmt.Sprintf("模型 %s 单笔 %.6f,为该租户近 7 天均值的 %.1f 倍。"+ - "需确认是正常业务、刷量,还是定价配置写错", - r["model_name"].String(), cost, mult), - "查看用量日志", "AdminUsageLogs", - map[string]string{"tenant_id": r["tenant_id"].String()}, r["created_at"].GTime(), - )) - } - return items -} - // wbLowBalanceTenants 余额低于预警阈值的租户。 // 一个信号两个用途:流失预警 + 催充窗口。聚合成一条,不逐租户刷屏。 func (s *sAdmin) wbLowBalanceTenants(ctx context.Context) []wbItem { @@ -606,38 +554,53 @@ func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem { rows, err := g.DB().Ctx(ctx).Query(ctx, `SELECT job_name, last_status, last_started_at, last_error_message FROM sys_cron_jobs - WHERE last_status = 'failed' - OR last_started_at IS NULL - OR last_started_at <= NOW() - INTERVAL '1 hour' - ORDER BY last_started_at ASC NULLS FIRST - LIMIT 10`) + ORDER BY last_started_at ASC NULLS FIRST`) if err != nil { g.Log().Warningf(ctx, "workbench: 查询定时任务状态失败: %v", err) return nil } + // 注册表快照:任务名 → 调度表达式。停摆判定必须按各任务自身的调度周期换算, + // 固定 1 小时停摆线会把日任务(日对账、日清理等)一天里 23 个小时都误报成停摆。 + schedules := make(map[string]string, 16) + for _, j := range common.GetCronScheduler().ListJobs() { + schedules[j.Name] = j.Schedule + } + items := make([]wbItem, 0, len(rows)) for _, r := range rows { name := r["job_name"].String() startedAt := r["last_started_at"].GTime() failed := r["last_status"].String() == "failed" + // 停摆 = 距上次执行超过「正常间隔 × 2」,下限 1 小时—— + // 下限避免分钟级任务被一次调度抖动(锁等待、实例重启)误报 + interval := wbCronExpectedInterval(schedules, name) + stallAfter := max(2*interval, time.Hour) + stalled := startedAt != nil && time.Since(startedAt.Time) > stallAfter + + if !failed && !stalled && startedAt != nil { + continue + } + // 资金相关任务提级为 P0:这些停摆会累积资损,不能等到明天 sev := v1.WorkbenchSeverityP1 if isMoneyCriticalJob(name) { sev = v1.WorkbenchSeverityP0 } - desc := "" - if failed { + var desc string + switch { + case failed: desc = "最近一次执行失败" if e := strings.TrimSpace(r["last_error_message"].String()); e != "" { desc += ":" + truncate(e, 150) } - } else if startedAt == nil { + case startedAt == nil: desc = "自服务启动以来从未执行过,调度器可能未正常注册该任务" - } else { - desc = fmt.Sprintf("已 %s 未执行,任务可能已停摆", humanizeSince(startedAt)) + default: + desc = fmt.Sprintf("正常约 %s 执行一次,已 %s 未执行,任务可能已停摆", + humanizeDuration(interval), humanizeSince(startedAt)) } if isMoneyCriticalJob(name) { desc += "。该任务涉及资金结算,停摆会持续累积差错" @@ -654,6 +617,19 @@ func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem { return items } +// wbCronExpectedInterval 从任务注册表查正常执行间隔。 +// 任务不在注册表中(改名/下线后的遗留行)或表达式解析失败时退回保守兜底值: +// 宁可晚报一条真正死掉的任务,也不要每天误报一串健康任务。 +func wbCronExpectedInterval(schedules map[string]string, name string) time.Duration { + interval := wbCronStallFallbackInterval + if schedule, ok := schedules[name]; ok { + if d, err := common.ScheduleInterval(schedule); err == nil { + interval = d + } + } + return interval +} + // isMoneyCriticalJob 判断任务停摆是否会造成资损。 func isMoneyCriticalJob(name string) bool { n := strings.ToLower(name) @@ -922,7 +898,11 @@ func humanizeSince(t *gtime.Time) string { if t == nil { return "未知时长" } - d := time.Since(t.Time) + return humanizeDuration(time.Since(t.Time)) +} + +// humanizeDuration 把时长写成人话(分钟/小时/天)。 +func humanizeDuration(d time.Duration) string { if d < time.Minute { return "不到 1 分钟" } diff --git a/internal/logic/common/cron.go b/internal/logic/common/cron.go index 8ca7098..8f79250 100644 --- a/internal/logic/common/cron.go +++ b/internal/logic/common/cron.go @@ -370,6 +370,21 @@ func (cs *CronScheduler) Reschedule(ctx context.Context, name, schedule string) return nil } +// ScheduleInterval 解析 cron 表达式,估算两次触发之间的正常间隔。 +// 取「接下来两次触发时刻之差」,对分钟/小时/日级表达式及 @every 描述符均适用 +// (@every 的触发时刻相对计算起点平移,间隔不受影响)。解析失败(非法表达式)返回错误, +// 由调用方决定兜底策略——工作台的停摆判定靠它把「固定 1 小时」换成「按任务自身周期」。 +func ScheduleInterval(schedule string) (time.Duration, error) { + sched, err := cron.ParseStandard(schedule) + if err != nil { + return 0, gerror.Wrapf(err, "invalid cron schedule %q", schedule) + } + now := time.Now() + next1 := sched.Next(now) + next2 := sched.Next(next1) + return next2.Sub(next1), nil +} + // cronLockTTL 分布式锁过期时间:仅用于实例崩溃时的兜底自动释放(正常完成会主动释放)。 // 取值需大于绝大多数 job 的执行时长;过长则崩溃后会阻塞该 job 至多这么久。 const cronLockTTL = 10 * time.Minute diff --git a/internal/logic/common/cron_test.go b/internal/logic/common/cron_test.go index 369d212..988788e 100644 --- a/internal/logic/common/cron_test.go +++ b/internal/logic/common/cron_test.go @@ -3,6 +3,7 @@ package common import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,6 +94,32 @@ func TestReschedule_ValidExprReplacesEntry(t *testing.T) { assert.NotEqual(t, oldID, cs.entryIDs["job_c"], "条目 ID 应已更新") } +// TestScheduleInterval 停摆判定靠它把「固定 1 小时」换成「按任务自身周期」, +// 各类表达式的间隔必须算准——尤其日任务(24h),算错就会退回误报。 +func TestScheduleInterval(t *testing.T) { + cases := []struct { + schedule string + want time.Duration + }{ + {"* * * * *", time.Minute}, // 每分钟 + {"*/5 * * * *", 5 * time.Minute}, // 每 5 分钟 + {"*/10 * * * *", 10 * time.Minute}, // 每 10 分钟 + {"0 */6 * * *", 6 * time.Hour}, // 每 6 小时(update_check) + {"0 3 * * *", 24 * time.Hour}, // 每日 3 点(各类日清理) + {"20 5 * * *", 24 * time.Hour}, // 每日 5:20(计费日对账) + {"@every 5m", 5 * time.Minute}, // @every 描述符(渠道自动探测默认) + {"@every 90m", 90 * time.Minute}, // @every 大间隔(*/N 在 N>=60 时会失真,故用 @every) + } + for _, c := range cases { + got, err := ScheduleInterval(c.schedule) + require.NoError(t, err, "schedule %q 应可解析", c.schedule) + assert.Equal(t, c.want, got, "schedule %q 的正常间隔不符", c.schedule) + } + + _, err := ScheduleInterval("不是合法表达式") + assert.Error(t, err, "非法表达式应返回错误,由调用方兜底") +} + // TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers 回调在订阅 goroutine 内串行执行, // 一个回调 panic 不能带崩订阅循环或阻断后续回调。 func TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers(t *testing.T) { diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 8680230..6250eeb 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -9,7 +9,7 @@ import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue' import { useWatermark } from '@/composables/useWatermark' import { useSiteName } from '@/composables/useSiteName' import { - IconThunderbolt, + IconDesktop, IconDashboard, IconUserGroup, IconApps, @@ -306,7 +306,7 @@ const menuGroups = [ label: '数据看板', icon: IconDashboard, items: [ - { name: 'AdminWorkbench', label: '工作台', icon: IconThunderbolt }, + { name: 'AdminWorkbench', label: '工作台', icon: IconDesktop }, { name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard }, { name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand }, ], @@ -459,15 +459,15 @@ function handleClickOutside(e: MouseEvent) { // 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台, // 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存, // 60s 轮询不会额外压库。 -const workbenchBadges = ref>({}) const workbenchUrgent = ref(0) const workbenchTotal = ref(0) let badgeTimer: ReturnType | null = null -// 菜单项角标:工作台本身显示待办总数,其余菜单显示后端映射到该路由的待办数 +// 角标只挂工作台菜单:待办的排查线索只在工作台的描述文案里, +// 业务菜单里没有对应的定位入口,挂数字只会带来「进去了却找不到问题」的困惑 function badgeOf(name: string): number { if (name === 'AdminWorkbench') return workbenchTotal.value - return workbenchBadges.value[name] || 0 + return 0 } async function fetchWorkbenchBadges() { @@ -475,7 +475,6 @@ async function fetchWorkbenchBadges() { const res = await request.get('/admin/workbench/badges') const data = res.data?.data if (!data) return - workbenchBadges.value = data.menus || {} workbenchUrgent.value = data.urgent || 0 workbenchTotal.value = data.total || 0 } catch { diff --git a/web/admin/src/views/WorkbenchPage.vue b/web/admin/src/views/WorkbenchPage.vue index 40c0b61..1430a2d 100644 --- a/web/admin/src/views/WorkbenchPage.vue +++ b/web/admin/src/views/WorkbenchPage.vue @@ -533,6 +533,22 @@ onBeforeUnmount(() => { font-weight: 600; } +/* 卡片固定高度:告警再多也在卡片内部滚动,不无限撑开页面 */ +.wb-flow :deep(.arco-card-body) { + height: 640px; + overflow-y: auto; +} + +/* 细滚动条,贴合整体视觉 */ +.wb-flow :deep(.arco-card-body)::-webkit-scrollbar { + width: 6px; +} + +.wb-flow :deep(.arco-card-body)::-webkit-scrollbar-thumb { + background: var(--color-fill-3); + border-radius: 3px; +} + .wb-list { display: flex; flex-direction: column; @@ -596,8 +612,13 @@ onBeforeUnmount(() => { } /* ===== 空态:清空是正常状态 ===== */ +/* 卡片 body 定高后,空态在固定高度内垂直居中 */ .wb-empty { - padding: 48px 0; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; } @@ -793,6 +814,11 @@ onBeforeUnmount(() => { .wb-body { grid-template-columns: 1fr; } + + /* 单列布局下视口纵向空间更紧张,卡片定高同步收紧 */ + .wb-flow :deep(.arco-card-body) { + height: 560px; + } } @media (max-width: 767px) { @@ -814,5 +840,9 @@ onBeforeUnmount(() => { .wb-item__title { min-width: 100%; } + + .wb-flow :deep(.arco-card-body) { + height: 480px; + } } diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index a47ba5c..ca07ea5 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -38,7 +38,7 @@ const values = useFormValues() - +
启用 TLS