267 lines
8.8 KiB
Go
267 lines
8.8 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
|
|
v1 "github.com/qianfree/team-api/api/admin/v1"
|
|
"github.com/qianfree/team-api/internal/logic/common"
|
|
)
|
|
|
|
// ============================================================
|
|
// 工作台(运营收件箱)
|
|
//
|
|
// 核心约定:待办**不落库**,每次都从各业务源表实时派生。
|
|
// 因此「归零」是自动的 —— 工单被回复、告警被确认、渠道 Key 换好之后,
|
|
// 下一轮收集自然就查不到它了,不需要任何人来点「完成」,也不存在
|
|
// 「源表已修好但工作台还挂着」的残留。待办的唯一出路就是去源头解决。
|
|
//
|
|
// 收集器约定:
|
|
// 1. 每个 collector 独立失败不影响其他项 —— 任何一个查询报错只记 warning 并返回空,
|
|
// 绝不让整个工作台因为某张表出问题而白屏(工作台本身就是故障时最该能打开的页面)。
|
|
// 2. 不做全表实时 count:高频写入表(bil_usage_logs / aud_* )一律限定时间窗 + 走
|
|
// 既有索引,整份结果进 Redis 缓存 30s,菜单红点与页面共用同一份缓存。
|
|
// ============================================================
|
|
|
|
const (
|
|
// workbenchCacheKey 整份收集结果的缓存键。菜单红点轮询与页面刷新共用,
|
|
// 避免每个管理员每 30s 各打一遍库。
|
|
workbenchCacheKey = "workbench:summary:v1"
|
|
workbenchCacheTTL = 30 * time.Second
|
|
|
|
// 各项判定阈值。选值理由见对应 collector 注释。
|
|
wbFrozenStaleHours = 2 // 冻结超过 2 小时未释放视为异常
|
|
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% 且绝对额可观时告警
|
|
)
|
|
|
|
// wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。
|
|
// 工作台跨域聚合,必须按权限逐条过滤 —— 只有 support:view 的客服不该在工作台
|
|
// 看到钱包偏差和订单金额,否则等于绕过 RBAC 泄露财务数据。
|
|
type wbItem struct {
|
|
v1.WorkbenchItem
|
|
Perm string `json:"perm"`
|
|
}
|
|
|
|
// wbCollected 一次收集的完整结果(进缓存的就是它)。
|
|
type wbCollected struct {
|
|
Items []wbItem `json:"items"`
|
|
Models []v1.WorkbenchModelAvail `json:"models"`
|
|
Breakers []v1.WorkbenchBreaker `json:"breakers"`
|
|
Metrics []v1.WorkbenchMetric `json:"metrics"`
|
|
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"},
|
|
}
|
|
|
|
// ============================================================
|
|
// 对外接口
|
|
// ============================================================
|
|
|
|
// GetWorkbenchSummary 工作台汇总。
|
|
func (s *sAdmin) GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSummaryReq) (*v1.AdminWorkbenchSummaryRes, error) {
|
|
col := s.collectWorkbenchCached(ctx)
|
|
|
|
items := s.filterWorkbenchItems(ctx, col.Items)
|
|
|
|
// 分域计数基于过滤后的结果 —— 计数必须与用户实际看得到的列表一致,
|
|
// 否则会出现「显示 3 条但列表只有 1 条」的诡异体验。
|
|
domainStats := make([]v1.WorkbenchDomainStat, 0, 4)
|
|
for _, d := range []string{
|
|
v1.WorkbenchDomainAvailability, v1.WorkbenchDomainMoney,
|
|
v1.WorkbenchDomainCustomer, v1.WorkbenchDomainSystem,
|
|
} {
|
|
st := v1.WorkbenchDomainStat{Domain: d}
|
|
for _, it := range items {
|
|
if it.Domain != d {
|
|
continue
|
|
}
|
|
st.Total++
|
|
if it.Severity == v1.WorkbenchSeverityP0 {
|
|
st.Urgent++
|
|
}
|
|
}
|
|
domainStats = append(domainStats, st)
|
|
}
|
|
|
|
out := make([]v1.WorkbenchItem, 0, len(items))
|
|
for _, it := range items {
|
|
out = append(out, it.WorkbenchItem)
|
|
}
|
|
|
|
res := &v1.AdminWorkbenchSummaryRes{
|
|
Metrics: col.Metrics,
|
|
Items: out,
|
|
Domains: domainStats,
|
|
Models: col.Models,
|
|
Breakers: col.Breakers,
|
|
GeneratedAt: col.GeneratedAt,
|
|
}
|
|
if res.Metrics == nil {
|
|
res.Metrics = []v1.WorkbenchMetric{}
|
|
}
|
|
if res.Models == nil {
|
|
res.Models = []v1.WorkbenchModelAvail{}
|
|
}
|
|
if res.Breakers == nil {
|
|
res.Breakers = []v1.WorkbenchBreaker{}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// 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{},
|
|
}
|
|
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
|
|
}
|
|
|
|
// ============================================================
|
|
// 收集与过滤
|
|
// ============================================================
|
|
|
|
// collectWorkbenchCached 读缓存,未命中则重新收集。
|
|
// 缓存失败一律降级为直接收集 —— 工作台不能因为 Redis 抖动就打不开。
|
|
func (s *sAdmin) collectWorkbenchCached(ctx context.Context) *wbCollected {
|
|
if v, err := g.Redis().Get(ctx, workbenchCacheKey); err == nil && !v.IsEmpty() {
|
|
var col wbCollected
|
|
if json.Unmarshal(v.Bytes(), &col) == nil {
|
|
return &col
|
|
}
|
|
}
|
|
|
|
col := s.collectWorkbench(ctx)
|
|
|
|
if b, err := json.Marshal(col); err == nil {
|
|
if _, err := g.Redis().Do(ctx, "SET", workbenchCacheKey, b, "EX", int(workbenchCacheTTL.Seconds())); err != nil {
|
|
g.Log().Warningf(ctx, "workbench: 写缓存失败: %v", err)
|
|
}
|
|
}
|
|
return col
|
|
}
|
|
|
|
// filterWorkbenchItems 按当前管理员权限逐条过滤,再按严重度排序。
|
|
func (s *sAdmin) filterWorkbenchItems(ctx context.Context, items []wbItem) []wbItem {
|
|
userID := common.GetCtxUserID(ctx)
|
|
role, _ := ctx.Value("role").(string)
|
|
|
|
// 权限判定结果按 perm 记忆化:一次汇总最多 20 条待办但只有 ~10 种权限点,
|
|
// 逐条查库会把一次页面刷新放大成几十次 SQL。
|
|
permCache := make(map[string]bool, 12)
|
|
allowed := func(perm string) bool {
|
|
if perm == "" {
|
|
return true
|
|
}
|
|
if v, ok := permCache[perm]; ok {
|
|
return v
|
|
}
|
|
v := HasPermission(ctx, userID, role, perm)
|
|
permCache[perm] = v
|
|
return v
|
|
}
|
|
|
|
out := make([]wbItem, 0, len(items))
|
|
for _, it := range items {
|
|
if !allowed(it.Perm) {
|
|
continue
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
|
|
sevRank := map[string]int{
|
|
v1.WorkbenchSeverityP0: 0,
|
|
v1.WorkbenchSeverityP1: 1,
|
|
v1.WorkbenchSeverityP2: 2,
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
ri, rj := sevRank[out[i].Severity], sevRank[out[j].Severity]
|
|
if ri != rj {
|
|
return ri < rj
|
|
}
|
|
// 同 severity 下新发生的排前面;聚合类(OccurredAt 为空)排最后
|
|
if out[i].OccurredAt != out[j].OccurredAt {
|
|
if out[i].OccurredAt == "" {
|
|
return false
|
|
}
|
|
if out[j].OccurredAt == "" {
|
|
return true
|
|
}
|
|
return out[i].OccurredAt > out[j].OccurredAt
|
|
}
|
|
return out[i].Key < out[j].Key
|
|
})
|
|
return out
|
|
}
|
|
|
|
// collectWorkbench 依次跑各 collector。单个失败不影响整体。
|
|
func (s *sAdmin) collectWorkbench(ctx context.Context) *wbCollected {
|
|
col := &wbCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)}
|
|
|
|
models, breakers, availItems := s.wbCollectAvailability(ctx)
|
|
col.Models = models
|
|
col.Breakers = breakers
|
|
|
|
col.Items = append(col.Items, availItems...)
|
|
col.Items = append(col.Items, s.wbCollectMoney(ctx)...)
|
|
col.Items = append(col.Items, s.wbCollectCustomer(ctx)...)
|
|
col.Items = append(col.Items, s.wbCollectSystem(ctx)...)
|
|
|
|
col.Metrics = s.wbCollectMetrics(ctx)
|
|
return col
|
|
}
|
|
|
|
// wbNew 构造一条待办。
|
|
func wbNew(key, sev, domain, perm, title, desc, actionText, route string, query map[string]string, at *gtime.Time) wbItem {
|
|
it := wbItem{Perm: perm}
|
|
it.Key = key
|
|
it.Severity = sev
|
|
it.Domain = domain
|
|
it.Title = title
|
|
it.Desc = desc
|
|
it.ActionText = actionText
|
|
it.ActionRoute = route
|
|
it.ActionQuery = query
|
|
if at != nil {
|
|
it.OccurredAt = at.Format(time.RFC3339)
|
|
}
|
|
return it
|
|
}
|