diff --git a/Makefile b/Makefile
index bf792ed..055586f 100644
--- a/Makefile
+++ b/Makefile
@@ -54,7 +54,9 @@ tidy:
# GoFrame code generation
ctrl:
- gf gen ctrl
+ # 必须带 -m:本项目 controller 按 api 源文件合并生成,
+ # 裸 gf gen ctrl 会按方法拆分并与现有合并文件产生重复方法定义
+ gf gen ctrl -m
dao:
gf gen dao
diff --git a/api/admin/admin.go b/api/admin/admin.go
index e47a7c4..444604c 100644
--- a/api/admin/admin.go
+++ b/api/admin/admin.go
@@ -246,6 +246,7 @@ type IAdminV1 interface {
AdminSettingsGet(ctx context.Context, req *v1.AdminSettingsGetReq) (res *v1.AdminSettingsGetRes, err error)
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)
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)
@@ -281,4 +282,6 @@ type IAdminV1 interface {
UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (res *v1.UsageLogCleanupCreateRes, err error)
UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (res *v1.UsageLogCleanupListRes, err error)
UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (res *v1.UsageLogCleanupCancelRes, err error)
+ AdminWorkbenchSummary(ctx context.Context, req *v1.AdminWorkbenchSummaryReq) (res *v1.AdminWorkbenchSummaryRes, err error)
+ AdminWorkbenchBadge(ctx context.Context, req *v1.AdminWorkbenchBadgeReq) (res *v1.AdminWorkbenchBadgeRes, err error)
}
diff --git a/api/admin/v1/settings.go b/api/admin/v1/settings.go
index cfde1b9..2ba84cf 100644
--- a/api/admin/v1/settings.go
+++ b/api/admin/v1/settings.go
@@ -70,3 +70,25 @@ type AdminStorageTestRes struct {
ElapsedMs int64 `json:"elapsed_ms"` // 往返耗时(毫秒)
Message string `json:"message"` // 结果描述
}
+
+// AdminEmailTestReq 发送测试邮件,验证 SMTP 配置连通性。
+// 携带表单中的邮件配置(可含未保存的改动);字段为空或为掩码 "******" 时后端回落到
+// 已保存的值,从而支持「未保存直接测试」,与对象存储测试的行为保持一致。
+type AdminEmailTestReq struct {
+ g.Meta `path:"/settings/email/test" method:"post" mime:"json" tags:"管理后台-系统设置" summary:"发送测试邮件"`
+ Host string `json:"email_smtp_host" dc:"SMTP 服务器,空则回落已保存值"`
+ Port int `json:"email_smtp_port" dc:"SMTP 端口,0 则回落已保存值"`
+ Username string `json:"email_smtp_username" dc:"SMTP 用户名,空则回落已保存值"`
+ Password string `json:"email_smtp_password" dc:"SMTP 密码,空/掩码回落已保存值"`
+ From string `json:"email_smtp_from" dc:"发件人地址,空则回落已保存值"`
+ FromName string `json:"email_smtp_from_name" dc:"发件人显示名,空则回落已保存值/站点名称"`
+ UseTLS *bool `json:"email_smtp_tls" dc:"是否启用 TLS,不传则回落已保存值"`
+ To string `json:"to" dc:"收件人,留空则发送给当前登录管理员的邮箱"`
+}
+
+type AdminEmailTestRes struct {
+ Sent bool `json:"sent"` // 是否投递成功
+ Recipient string `json:"recipient"` // 实际收件人
+ ElapsedMs int64 `json:"elapsed_ms"` // 耗时(毫秒)
+ Message string `json:"message"` // 结果描述
+}
diff --git a/api/admin/v1/workbench.go b/api/admin/v1/workbench.go
new file mode 100644
index 0000000..9b84109
--- /dev/null
+++ b/api/admin/v1/workbench.go
@@ -0,0 +1,106 @@
+package v1
+
+import (
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// ============================================================
+// 工作台(运营收件箱)
+//
+// 与仪表盘(/dashboard)的分工:仪表盘回答「现在怎么样」,工作台回答「我该做什么」。
+// 只有满足「有人要处理 + 不处理会恶化 + 能被清空归零」三条的事才进工作台,
+// 纯统计一律留在仪表盘。
+// ============================================================
+
+// === 严重度 / 域 常量(与前端约定,勿随意改动字面量)===
+const (
+ WorkbenchSeverityP0 = "p0" // 紧急:正在影响服务可用性或资金安全
+ WorkbenchSeverityP1 = "p1" // 今日:当天要处理
+ WorkbenchSeverityP2 = "p2" // 本周:可排期
+
+ WorkbenchDomainAvailability = "availability" // 服务可用性
+ WorkbenchDomainMoney = "money" // 资金安全
+ WorkbenchDomainCustomer = "customer" // 客户运营
+ WorkbenchDomainSystem = "system" // 系统安全
+)
+
+// WorkbenchItem 一条待办。
+//
+// Key 是稳定可重算的业务标识(如 model_no_channel:claude-sonnet-4),
+// 供前端做列表 key 与去重 —— 同一异常在两次收集之间必须算出同一个 Key,
+// 所以禁止把时间戳、随机数或行 ID 拼进 Key(除非该行本身就是待办对象)。
+type WorkbenchItem struct {
+ Key string `json:"key" dc:"待办唯一标识(稳定可重算,用于静音匹配)"`
+ Severity string `json:"severity" dc:"严重度:p0=紧急 / p1=今日 / p2=本周"`
+ Domain string `json:"domain" dc:"所属域:availability / money / customer / system"`
+ Title string `json:"title" dc:"标题(一句话说清是什么问题)"`
+ Desc string `json:"desc" dc:"补充说明(影响面、判断依据)"`
+ // OccurredAt 为空表示该待办是聚合计数(如「5 条反馈待处理」)而非单点事件。
+ OccurredAt string `json:"occurred_at" dc:"发生时间(RFC3339),聚合类待办为空"`
+ ActionText string `json:"action_text" dc:"跳转按钮文案"`
+ ActionRoute string `json:"action_route" dc:"跳转目标前端路由名"`
+ ActionQuery map[string]string `json:"action_query" dc:"跳转时附带的查询参数(自动带上筛选条件)"`
+}
+
+// WorkbenchMetric 首屏关键数字。
+type WorkbenchMetric struct {
+ Key string `json:"key" dc:"指标标识"`
+ Label string `json:"label" dc:"展示名"`
+ Value float64 `json:"value" dc:"数值"`
+ // Unit 决定前端格式化方式:money=本位币金额 / percent=百分比 / count=计数
+ Unit string `json:"unit" dc:"单位类型:money / percent / count"`
+ // Growth 为 nil 表示该指标没有同比口径(如「余额告警租户数」)
+ Growth *float64 `json:"growth" dc:"环比变化百分比;null 表示无对比口径"`
+ Sub string `json:"sub" dc:"副标题(说明对比口径或统计窗口)"`
+}
+
+// WorkbenchDomainStat 分域计数。
+type WorkbenchDomainStat struct {
+ Domain string `json:"domain"`
+ Total int `json:"total" dc:"该域待办总数"`
+ Urgent int `json:"urgent" dc:"该域 P0 数量"`
+}
+
+// WorkbenchModelAvail 模型可用渠道速览。
+type WorkbenchModelAvail struct {
+ Model string `json:"model"`
+ Available int `json:"available" dc:"熔断均为 CLOSED 的可用渠道数"`
+ Total int `json:"total" dc:"承载该模型的 active 渠道数"`
+}
+
+// WorkbenchBreaker 正在熔断的渠道×模型。
+type WorkbenchBreaker struct {
+ ChannelId int64 `json:"channel_id"`
+ ChannelName string `json:"channel_name"`
+ Model string `json:"model"`
+ ChannelLevel bool `json:"channel_level" dc:"true=渠道级熔断(该渠道所有模型受影响);false=仅该模型被隔离"`
+ HalfOpen bool `json:"half_open" dc:"是否处于半开探测态"`
+}
+
+// === 工作台汇总 ===
+
+type AdminWorkbenchSummaryReq struct {
+ g.Meta `path:"/workbench/summary" method:"get" mime:"json" tags:"管理后台-工作台" summary:"工作台汇总"`
+}
+
+type AdminWorkbenchSummaryRes struct {
+ Metrics []WorkbenchMetric `json:"metrics" dc:"首屏关键数字"`
+ Items []WorkbenchItem `json:"items" dc:"待办列表(已按 p0→p1→p2 排序,已剔除静音项)"`
+ Domains []WorkbenchDomainStat `json:"domains" dc:"分域计数"`
+ Models []WorkbenchModelAvail `json:"models" dc:"模型可用渠道速览"`
+ Breakers []WorkbenchBreaker `json:"breakers" dc:"正在熔断的渠道×模型"`
+ GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"`
+}
+
+// === 菜单红点 ===
+
+// AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。
+// 与 summary 共用同一份缓存,不会额外压库。
+type AdminWorkbenchBadgeReq struct {
+ 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 数量,控制角标转红"`
+}
diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go
index 59be8bb..a67deac 100644
--- a/api/tenant/tenant.go
+++ b/api/tenant/tenant.go
@@ -44,6 +44,8 @@ type ITenantV1 interface {
TenantBalancePrediction(ctx context.Context, req *v1.TenantBalancePredictionReq) (res *v1.TenantBalancePredictionRes, err error)
TenantBudgetAlerts(ctx context.Context, req *v1.TenantBudgetAlertsReq) (res *v1.TenantBudgetAlertsRes, err error)
TenantMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (res *v1.TenantMemberUsageRankingRes, err error)
+ TenantTeamHealth(ctx context.Context, req *v1.TenantTeamHealthReq) (res *v1.TenantTeamHealthRes, err error)
+ TenantProjectBudget(ctx context.Context, req *v1.TenantProjectBudgetReq) (res *v1.TenantProjectBudgetRes, err error)
TenantSendCode(ctx context.Context, req *v1.TenantSendCodeReq) (res *v1.TenantSendCodeRes, err error)
TenantResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (res *v1.TenantResetPasswordRes, err error)
TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error)
diff --git a/api/tenant/v1/dashboard.go b/api/tenant/v1/dashboard.go
index a1083a7..3dcbab6 100644
--- a/api/tenant/v1/dashboard.go
+++ b/api/tenant/v1/dashboard.go
@@ -4,20 +4,40 @@ import "github.com/gogf/gf/v2/frame/g"
// === 租户仪表盘 ===
+// ---------- 1. 概览 ----------
+
type TenantDashboardReq struct {
g.Meta `path:"/dashboard" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"仪表盘概览"`
}
type TenantDashboardRes struct {
- Today map[string]any `json:"today"`
- Month map[string]any `json:"month"`
- Wallet map[string]any `json:"wallet"`
- Rpm int64 `json:"rpm" dc:"最近60秒滑动窗口请求数(本租户)"`
- Tpm int64 `json:"tpm" dc:"最近60秒滑动窗口token数(本租户)"`
- ActiveKeys int `json:"active_keys"`
- MemberCount int `json:"member_count"`
+ Today map[string]any `json:"today"`
+ Month map[string]any `json:"month"`
+ Wallet map[string]any `json:"wallet"`
+ Rpm int64 `json:"rpm" dc:"最近60秒滑动窗口请求数(本租户)"`
+ Tpm int64 `json:"tpm" dc:"最近60秒滑动窗口token数(本租户)"`
+ ActiveKeys int `json:"active_keys"`
+ MemberCount int `json:"member_count"`
+ CostTrend TenantCostTrend `json:"cost_trend" dc:"本月消费与上月同期对比"`
+ Waste TenantWasteStat `json:"waste" dc:"本月失败请求造成的无效支出"`
}
+type TenantCostTrend struct {
+ CurrentCost float64 `json:"current_cost" dc:"本月至今消费"`
+ PreviousCost float64 `json:"previous_cost" dc:"上月同期(相同已过天数)消费"`
+ DeltaPercent float64 `json:"delta_percent" dc:"环比变化百分比;上月同期为 0 时记 0"`
+ HasPrevious bool `json:"has_previous" dc:"上月同期是否有数据,false 时前端不显示环比"`
+}
+
+type TenantWasteStat struct {
+ WastedCost float64 `json:"wasted_cost" dc:"本月失败请求已产生的费用"`
+ SharePercent float64 `json:"share_percent" dc:"占本月总消费的百分比"`
+ FailedRequests int64 `json:"failed_requests" dc:"本月失败请求数"`
+ RetriedRequests int64 `json:"retried_requests" dc:"本月触发过重试的请求数"`
+}
+
+// ---------- 2. 趋势与分布 ----------
+
type TenantTokenTrendsReq struct {
g.Meta `path:"/dashboard/token-trends" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"Token趋势"`
Days int `json:"days" in:"query" d:"7"`
@@ -36,6 +56,8 @@ type TenantModelDistributionRes struct {
List []map[string]any `json:"list"`
}
+// ---------- 3. 余额预测 ----------
+
type TenantBalancePredictionReq struct {
g.Meta `path:"/dashboard/balance-prediction" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"余额预测"`
}
@@ -49,15 +71,37 @@ type TenantBalancePredictionRes struct {
Message *string `json:"message,omitempty"`
}
+// ---------- 4. 预算告警 ----------
+
type TenantBudgetAlertsReq struct {
g.Meta `path:"/dashboard/budget-alerts" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"预算告警"`
}
type TenantBudgetAlertsRes struct {
- Members []map[string]any `json:"members"`
- Projects []map[string]any `json:"projects"`
+ Members []TenantMemberAlert `json:"members"`
+ Projects []TenantProjectAlert `json:"projects"`
}
+type TenantMemberAlert struct {
+ Id int64 `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ QuotaLimit float64 `json:"quota_limit"`
+ QuotaUsed float64 `json:"quota_used"`
+ UsagePercent float64 `json:"usage_percent"`
+ NextResetAt string `json:"next_reset_at,omitempty" dc:"周期额度的下次重置时间"`
+}
+
+type TenantProjectAlert struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Budget float64 `json:"budget"`
+ Used float64 `json:"used"`
+ UsagePercent float64 `json:"usage_percent"`
+}
+
+// ---------- 5. 成员用量排行 ----------
+
type TenantMemberUsageRankingReq struct {
g.Meta `path:"/dashboard/member-usage-ranking" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"成员用量排名"`
Days int `json:"days" in:"query" d:"7"`
@@ -65,5 +109,54 @@ type TenantMemberUsageRankingReq struct {
}
type TenantMemberUsageRankingRes struct {
- List []map[string]any `json:"list"`
+ List []TenantMemberUsageItem `json:"list"`
+ PrevAvailable bool `json:"prev_available" dc:"对比周期是否仍在用量日志保留期内;false 时前端应隐藏环比列"`
+}
+
+type TenantMemberUsageItem struct {
+ UserId int64 `json:"user_id"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ Requests int64 `json:"requests"`
+ InputTokens int64 `json:"input_tokens"`
+ OutputTokens int64 `json:"output_tokens"`
+ TotalCost float64 `json:"total_cost"`
+ DeltaPercent float64 `json:"delta_percent" dc:"与上一同长周期相比的消费变化百分比"`
+ HasPrevious bool `json:"has_previous" dc:"上一周期是否有数据,false 时前端不显示环比"`
+ QuotaPercent float64 `json:"quota_percent" dc:"个人额度使用百分比;未设额度时为 0"`
+ HasQuota bool `json:"has_quota" dc:"是否设置了个人额度"`
+}
+
+// ---------- 6. 团队运行质量 ----------
+
+type TenantTeamHealthReq struct {
+ g.Meta `path:"/dashboard/team-health" method:"get" mime:"application/json" tags:"租户控制台-仪表盘" summary:"团队运行质量"`
+}
+
+type TenantTeamHealthRes struct {
+ TotalRequests int64 `json:"total_requests" dc:"本月请求总数"`
+ SuccessRate float64 `json:"success_rate" dc:"成功率(0-1)"`
+ P95Ms float64 `json:"p95_ms" dc:"成功请求的 P95 延迟"`
+ AvgFirstTokenMs float64 `json:"avg_first_token_ms" dc:"平均首 Token 延迟"`
+ CacheHitRatio float64 `json:"cache_hit_ratio" dc:"缓存命中率(0-1)"`
+ CacheReadTokens int64 `json:"cache_read_tokens" dc:"缓存命中的 Token 数"`
+}
+
+// ---------- 7. 项目预算 ----------
+
+type TenantProjectBudgetReq struct {
+ g.Meta `path:"/dashboard/project-budget" method:"get" mime:"application/json" tags:"租户控制台-仪表盘" summary:"项目预算使用情况"`
+}
+
+type TenantProjectBudgetRes struct {
+ List []TenantProjectBudgetItem `json:"list"`
+}
+
+type TenantProjectBudgetItem struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Budget float64 `json:"budget" dc:"累计预算上限;0 表示不限"`
+ Used float64 `json:"used" dc:"累计已用(与预算执行同源,取自 bil_transactions)"`
+ UsagePercent float64 `json:"usage_percent" dc:"已用百分比;未设预算时为 0"`
+ HasBudget bool `json:"has_budget" dc:"是否设置了预算"`
}
diff --git a/api/tenant/v1/personal_dashboard.go b/api/tenant/v1/personal_dashboard.go
index 85ecd44..0d25135 100644
--- a/api/tenant/v1/personal_dashboard.go
+++ b/api/tenant/v1/personal_dashboard.go
@@ -11,13 +11,14 @@ type PersonalDashboardReq struct {
}
type PersonalDashboardRes struct {
- Today PersonalDayStats `json:"today"`
- Month PersonalDayStats `json:"month"`
- ErrorRate PersonalErrorRate `json:"error_rate"`
- Latency PersonalLatency `json:"latency"`
- Cache PersonalCache `json:"cache"`
- RequestTypes []PersonalReqTypeItem `json:"request_types"`
- Quota *PersonalQuotaStatus `json:"quota,omitempty"`
+ Today PersonalDayStats `json:"today"`
+ Month PersonalDayStats `json:"month"`
+ ErrorRate PersonalErrorRate `json:"error_rate"`
+ Latency PersonalLatency `json:"latency"`
+ Cache PersonalCache `json:"cache"`
+ RequestTypes []PersonalReqTypeItem `json:"request_types"`
+ Quota *PersonalQuotaStatus `json:"quota,omitempty"`
+ RecentFailures []PersonalFailureItem `json:"recent_failures" dc:"本月最近的失败调用,最多 5 条"`
}
type PersonalDayStats struct {
@@ -49,6 +50,7 @@ type PersonalCache struct {
CacheReadTokens int64 `json:"cache_read_tokens"`
TotalInputTokens int64 `json:"total_input_tokens"`
HitRatio float64 `json:"hit_ratio"`
+ SavedCost float64 `json:"saved_cost" dc:"缓存命中相对全价输入所省下的费用(估算)"`
}
type PersonalReqTypeItem struct {
@@ -67,6 +69,13 @@ type PersonalQuotaStatus struct {
NextResetAt string `json:"next_reset_at,omitempty"`
}
+type PersonalFailureItem struct {
+ Status string `json:"status" dc:"error / timeout / cancelled"`
+ ModelName string `json:"model_name"`
+ ErrorMessage string `json:"error_message"`
+ CreatedAt string `json:"created_at"`
+}
+
// ---------- 2. Token Trends ----------
type PersonalTokenTrendsReq struct {
diff --git a/api/tenant/v1/project_tenant.go b/api/tenant/v1/project_tenant.go
index ef95dbd..355b344 100644
--- a/api/tenant/v1/project_tenant.go
+++ b/api/tenant/v1/project_tenant.go
@@ -69,7 +69,8 @@ type TenantProjectGetRes struct {
CreatedBy int64 `json:"created_by"`
ActiveKeys int `json:"active_keys"`
TotalKeys int `json:"total_keys"`
- MonthCost float64 `json:"month_cost"`
+ MonthCost float64 `json:"month_cost" dc:"本月用量成本(列表价口径,非预算口径)"`
+ BudgetUsed float64 `json:"budget_used" dc:"预算已用(累计实扣,与预算强制执行同源)"`
MonthRequests int64 `json:"month_requests"`
}
diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md
index e7a7d35..7a1498d 100644
--- a/docs/reference/goframe-conventions.md
+++ b/docs/reference/goframe-conventions.md
@@ -815,3 +815,29 @@ HMGET 对不存在的 key/字段返回 Redis nil,但 gredis 把它们包装成
- 同一份状态有多个读取方时(调度用 gconv、管理后台用 Sscanf),**解析方式必须统一**,否则同一数据在不同页面表现不同,故障期误导排查方向。
排查信号:某项指标「本该回落默认值却全是 0」;同一数据 UI 显示"无数据"而后台计算按 0 处理;`grep -n 'IsNil()' ` 命中 Redis 读取路径。
+
+### 2026-08-31:`g.Cfg().MustGet` 读取不存在的配置段静默返回空值,告警邮件长期发送失败
+
+**问题**:监控告警的邮件通知从上线起就没成功发出过,`ntf_send_log` 里堆满 `status=failed` 的记录,错误信息是 SMTP 连接失败——但管理后台「系统设置 → 邮件配置」里 SMTP 配置完全正常,验证码等其他邮件都能正常投递。
+
+**原因**:`internal/logic/monitor/alert_notify.go` 的 `sendAlertEmailToAdmins` 自己拼 `EmailConfig`,读的是配置文件:
+
+```go
+sender := common.NewEmailSender(&common.EmailConfig{
+ Host: g.Cfg().MustGet(ctx, "email.smtp.host").String(), // ← 配置文件里根本没有 email 段
+ Port: g.Cfg().MustGet(ctx, "email.smtp.port").Int(),
+ ...
+})
+```
+
+`manifest/config/config.yaml` 中并不存在 `email` 段。`MustGet` 的 `Must` 只针对**读取过程出错**(配置文件损坏等)才 panic,**「键不存在」不是错误**:它返回一个空 `*gvar.Var`,`.String()` 得到 `""`、`.Int()` 得到 `0`。于是 Host 为空、Port 为 0,每封告警邮件都要走满 3 次重试才失败,而且失败原因看起来像"SMTP 服务器有问题",与真正的原因(配置压根没读到)南辕北辙。
+
+**修复**:改为与其余三条发信链路一致,走设置注册表 `common.EmailConfigFromOptions(ctx)`(数据源 `sys_options`),配置缺失时返回明确错误并直接跳过发送。至此邮件配置的唯一来源是数据库,配置文件不再参与。
+
+**正确做法(通用规则)**:
+
+- **`MustGet` 不保证键存在**——它只保证读取过程无错。对必填配置要显式判空并给出可读错误,不要让空值一路流进业务逻辑变成"连不上/额度为 0/超时 0 秒"这类误导性症状。
+- **运行期可由管理员调整的配置一律走设置注册表 `common.Config()`(`sys_options`),不要放配置文件**:配置文件只承载启动期基础设施配置(数据库、Redis、监听端口)。两套来源并存时,改了后台却不生效、或某条链路读到空配置,是最难排查的一类问题。
+- 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。
+
+排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。
diff --git a/internal/controller/admin/admin_v1_settings.go b/internal/controller/admin/admin_v1_settings.go
index efbdf1b..17ef01b 100644
--- a/internal/controller/admin/admin_v1_settings.go
+++ b/internal/controller/admin/admin_v1_settings.go
@@ -19,3 +19,6 @@ func (c *ControllerV1) AdminSettingsUpdate(ctx context.Context, req *v1.AdminSet
func (c *ControllerV1) AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error) {
return service.Admin().TestStorageConfig(ctx, req)
}
+func (c *ControllerV1) AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error) {
+ return service.Admin().TestEmailConfig(ctx, req)
+}
diff --git a/internal/controller/admin/admin_v1_workbench.go b/internal/controller/admin/admin_v1_workbench.go
new file mode 100644
index 0000000..a444679
--- /dev/null
+++ b/internal/controller/admin/admin_v1_workbench.go
@@ -0,0 +1,15 @@
+package admin
+
+import (
+ "context"
+
+ "github.com/qianfree/team-api/api/admin/v1"
+ "github.com/qianfree/team-api/internal/service"
+)
+
+func (c *ControllerV1) AdminWorkbenchSummary(ctx context.Context, req *v1.AdminWorkbenchSummaryReq) (res *v1.AdminWorkbenchSummaryRes, err error) {
+ return service.Admin().GetWorkbenchSummary(ctx, req)
+}
+func (c *ControllerV1) AdminWorkbenchBadge(ctx context.Context, req *v1.AdminWorkbenchBadgeReq) (res *v1.AdminWorkbenchBadgeRes, err error) {
+ return service.Admin().GetWorkbenchBadges(ctx, req)
+}
diff --git a/internal/controller/tenant/tenant_v1_dashboard.go b/internal/controller/tenant/tenant_v1_dashboard.go
index d500943..afa571d 100644
--- a/internal/controller/tenant/tenant_v1_dashboard.go
+++ b/internal/controller/tenant/tenant_v1_dashboard.go
@@ -25,3 +25,9 @@ func (c *ControllerV1) TenantBudgetAlerts(ctx context.Context, req *v1.TenantBud
func (c *ControllerV1) TenantMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (res *v1.TenantMemberUsageRankingRes, err error) {
return service.Tenant().GetMemberUsageRanking(ctx, req)
}
+func (c *ControllerV1) TenantTeamHealth(ctx context.Context, req *v1.TenantTeamHealthReq) (res *v1.TenantTeamHealthRes, err error) {
+ return service.Tenant().TeamHealth(ctx, req)
+}
+func (c *ControllerV1) TenantProjectBudget(ctx context.Context, req *v1.TenantProjectBudgetReq) (res *v1.TenantProjectBudgetRes, err error) {
+ return service.Tenant().ProjectBudget(ctx, req)
+}
diff --git a/internal/dispatchadapter/catalog.go b/internal/dispatchadapter/catalog.go
index 26386d3..e1eeb94 100644
--- a/internal/dispatchadapter/catalog.go
+++ b/internal/dispatchadapter/catalog.go
@@ -216,6 +216,59 @@ func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState {
return out
}
+// ModelAvail 单个模型的渠道可用性视图(工作台「零可用渠道」检测用)。
+// Total 为目录快照中承载该模型的 active 渠道数(disabled / 无 enabled 能力的渠道不在快照里),
+// Available 为其中渠道级与模型级熔断均为 CLOSED 的渠道数。
+// Available=0 且 Total>0 即「配了渠道但一条都用不了」——最容易被大盘总量掩盖的静默故障。
+type ModelAvail struct {
+ Available int
+ Total int
+ Breaking []ModelBreak
+}
+
+// ModelBreak 一条正在熔断的渠道×模型记录。
+type ModelBreak struct {
+ ChannelID int64
+ ChannelName string
+ Model string
+ // ChannelLevel 为 true 表示渠道级熔断(该渠道所有模型都受影响),
+ // false 表示仅该模型被隔离(模型级熔断)。
+ ChannelLevel bool
+ HalfOpen bool // 处于半开探测态
+}
+
+// ModelAvailability 返回目录快照中每个模型的渠道可用性与正在熔断的渠道×模型列表。
+// 目录未初始化时返回 nil,调用方按「无数据」处理,不要当成故障。
+// 并发安全:current 为 atomic.Pointer,Load() 取到的快照不可变,无锁。
+func (c *Catalog) ModelAvailability() map[string]ModelAvail {
+ idx := c.current.Load()
+ if idx == nil {
+ return nil
+ }
+ out := make(map[string]ModelAvail, len(idx.byModel))
+ for model, chans := range idx.byModel {
+ av := ModelAvail{Total: len(chans)}
+ for _, ch := range chans {
+ chOpen := ch.Breaker != dispatch.BreakerClosed
+ mdOpen := ch.ModelBreaker != dispatch.BreakerClosed
+ if !chOpen && !mdOpen {
+ av.Available++
+ continue
+ }
+ av.Breaking = append(av.Breaking, ModelBreak{
+ ChannelID: ch.ID,
+ ChannelName: ch.Name,
+ Model: model,
+ ChannelLevel: chOpen,
+ HalfOpen: (chOpen && ch.Breaker == dispatch.BreakerHalfOpen) ||
+ (!chOpen && ch.ModelBreaker == dispatch.BreakerHalfOpen),
+ })
+ }
+ out[model] = av
+ }
+ return out
+}
+
// Invalidate 主动触发一次刷新(渠道/能力/Key 管理操作后调用;跨实例走 pub/sub)。
func (c *Catalog) Invalidate() {
select {
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/dashboard.go b/internal/logic/admin/dashboard.go
index bb2e7f9..b7d7742 100644
--- a/internal/logic/admin/dashboard.go
+++ b/internal/logic/admin/dashboard.go
@@ -798,7 +798,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
}, nil
}
-// GetWalletInfo 获取租户钱包信息(管理后台)
+// GetWalletInfo 获取租户钱包信息(管理后台)。
+// balance/frozen_balance 以 Redis 权威值为准(Redis 是资金提交点,调整余额/线下入账后立即可见),
+// DB 侧余额是物化器周期覆盖的滞后副本,仅作钱包行存在性校验与 Redis 不可用时的降级展示。
func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) {
type walletRow struct {
ID int64 `json:"id"`
@@ -815,11 +817,19 @@ func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq)
return nil, common.NewNotFoundError("钱包")
}
- return &v1.AdminWalletInfoRes{
+ res := &v1.AdminWalletInfoRes{
Balance: w.Balance,
FrozenBalance: w.FrozenBalance,
WarningThreshold: w.WarningThreshold,
- }, nil
+ }
+
+ // 覆盖为 Redis 实时余额(钱包 hash 缺失时 GetWallet 内部触发灾难恢复重建)
+ if wallet, err := billing.GetWallet(ctx, req.TenantID); err == nil {
+ res.Balance = wallet.Balance
+ res.FrozenBalance = wallet.FrozenBalance
+ }
+
+ return res, nil
}
// GetWalletTransactions 获取租户钱包交易流水(管理后台)
@@ -905,6 +915,9 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet
return nil, err
}
+ // 清除钱包静态字段缓存,让 GetWallet 读到新阈值(对齐租户端 UpdateWarningThreshold)
+ billing.InvalidateWalletStaticCache(ctx, req.TenantID)
+
// 阈值变更后重置预警标记,使新阈值能触发新的预警
billing.ResetLowBalanceNotified(ctx, req.TenantID)
diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go
index 53ec24d..31ae47a 100644
--- a/internal/logic/admin/settings.go
+++ b/internal/logic/admin/settings.go
@@ -15,10 +15,14 @@ import (
"github.com/google/uuid"
v1 "github.com/qianfree/team-api/api/admin/v1"
+ "github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/common"
)
+// emailTestTemplateCode 测试邮件在 ntf_send_log 中的模板编码,便于在「邮件发送记录」里筛出。
+const emailTestTemplateCode = "_test"
+
// GetSettingsCategories returns all available setting categories.
func (s *sAdmin) GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error) {
categories := common.Categories
@@ -243,3 +247,124 @@ func (s *sAdmin) TestStorageConfig(ctx context.Context, req *v1.AdminStorageTest
res.Message = "对象存储配置正常:测试图片上传、下载校验通过"
return res, nil
}
+
+// TestEmailConfig 发送一封测试邮件,验证 SMTP 配置是否可用。
+//
+// 与 TestStorageConfig 同构:请求体携带表单中的邮件配置(可能尚未保存),字段为空或为掩码
+// "******" 时回落到已保存的值,从而支持管理员在「未点保存」的状态下直接测试改动。
+// 收件人留空时发送给当前登录管理员的邮箱。
+//
+// 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试
+// 三次也不会变好),不该让管理员多等十几秒的退避。
+func (s *sAdmin) TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error) {
+ cfg := &common.EmailConfig{
+ Host: strings.TrimSpace(req.Host),
+ Port: req.Port,
+ Username: strings.TrimSpace(req.Username),
+ Password: req.Password,
+ From: strings.TrimSpace(req.From),
+ FromName: strings.TrimSpace(req.FromName),
+ }
+
+ // 空 / 掩码 → 回落已保存值(邮件配置的唯一来源是 sys_options,不读配置文件)
+ if cfg.Host == "" {
+ cfg.Host = common.Config().GetString(ctx, "email_smtp_host")
+ }
+ if cfg.Port == 0 {
+ cfg.Port = common.Config().GetInt(ctx, "email_smtp_port")
+ }
+ if cfg.Username == "" {
+ cfg.Username = common.Config().GetString(ctx, "email_smtp_username")
+ }
+ if cfg.Password == "" || cfg.Password == "******" {
+ cfg.Password = common.Config().GetString(ctx, "email_smtp_password")
+ }
+ if cfg.From == "" {
+ cfg.From = common.Config().GetString(ctx, "email_smtp_from")
+ }
+ if cfg.FromName == "" {
+ cfg.FromName = common.Config().GetString(ctx, "email_smtp_from_name")
+ }
+ // 未传 TLS 开关时沿用已保存值(*bool 区分「没传」和「显式关闭」)
+ if req.UseTLS != nil {
+ cfg.UseTLS = *req.UseTLS
+ } else {
+ cfg.UseTLS = common.Config().GetBool(ctx, "email_smtp_tls")
+ }
+
+ if cfg.Host == "" || cfg.From == "" {
+ return nil, common.NewBadRequestError("请先填写 SMTP 服务器和发件人地址")
+ }
+ if cfg.Port == 0 {
+ cfg.Port = 587
+ }
+ // 与 EmailConfigFromOptions 保持一致:未配置发件人名称时回落站点名称
+ if cfg.FromName == "" {
+ cfg.FromName = common.Config().GetString(ctx, "site_name")
+ }
+
+ recipient, err := s.resolveTestEmailRecipient(ctx, req.To)
+ if err != nil {
+ return nil, err
+ }
+
+ siteName := common.Config().GetString(ctx, "site_name")
+ now := time.Now().Format("2006-01-02 15:04:05")
+ body := fmt.Sprintf(`
+
+
邮件配置测试
+
这是一封来自 %s 的测试邮件,收到即表示 SMTP 配置可用。
+
发件人:%s
发送时间:%s
+
+ `, siteName, cfg.From, now)
+
+ // 单次投递 + 20s 上限:Send/SendOnce 内的等待可被 ctx 打断,超时能真正生效
+ testCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
+ defer cancel()
+
+ start := time.Now()
+ sendErr := common.NewEmailSender(cfg).SendOnce(testCtx, &common.EmailMessage{
+ To: recipient,
+ Subject: fmt.Sprintf("[%s] 邮件配置测试", siteName),
+ BodyHTML: body,
+ TemplateCode: emailTestTemplateCode,
+ })
+ elapsed := time.Since(start).Milliseconds()
+ if sendErr != nil {
+ g.Log().Warningf(ctx, "email config test: send to %s failed: %v", recipient, sendErr)
+ return nil, common.NewBadRequestError("测试邮件发送失败:" + sendErr.Error())
+ }
+
+ return &v1.AdminEmailTestRes{
+ Sent: true,
+ Recipient: recipient,
+ ElapsedMs: elapsed,
+ Message: "测试邮件已发送至 " + recipient + ",请查收(含垃圾邮件箱)",
+ }, nil
+}
+
+// resolveTestEmailRecipient 解析测试邮件收件人:显式传入优先,留空则取当前登录管理员的邮箱。
+func (s *sAdmin) resolveTestEmailRecipient(ctx context.Context, to string) (string, error) {
+ recipient := strings.TrimSpace(to)
+ if recipient == "" {
+ var admin *struct {
+ Email string `json:"email"`
+ }
+ if err := dao.SysAdminUsers.Ctx(ctx).
+ Where("id", common.GetCtxUserID(ctx)).
+ Fields("email").
+ Scan(&admin); common.IgnoreScanNoRows(err) != nil {
+ return "", err
+ }
+ if admin != nil {
+ recipient = strings.TrimSpace(admin.Email)
+ }
+ }
+ if recipient == "" {
+ return "", common.NewBadRequestError("请填写收件人,或先为当前管理员账号配置邮箱")
+ }
+ if !common.IsValidEmail(recipient) {
+ return "", common.NewBadRequestError("收件人邮箱格式不正确")
+ }
+ return recipient, nil
+}
diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go
index d265f8b..38308f8 100644
--- a/internal/logic/admin/tenant.go
+++ b/internal/logic/admin/tenant.go
@@ -3,6 +3,7 @@ package admin
import (
"context"
"encoding/json"
+ "strconv"
"strings"
"github.com/gogf/gf/v2/database/gdb"
@@ -424,16 +425,20 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
memberCount, _ := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.Id).Count()
- // Get wallet balance
- var wallet *struct {
- Balance string `json:"balance"`
- }
- _ = dao.BilWallets.Ctx(ctx).
- Where("tenant_id", req.Id).Scan(&wallet)
-
+ // Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底
walletBalance := "0"
- if wallet != nil && wallet.Balance != "" {
- walletBalance = wallet.Balance
+ if w, err := billing.GetWallet(ctx, req.Id); err == nil {
+ walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64)
+ } else {
+ var wallet *struct {
+ Balance string `json:"balance"`
+ }
+ _ = dao.BilWallets.Ctx(ctx).
+ Where("tenant_id", req.Id).Scan(&wallet)
+
+ if wallet != nil && wallet.Balance != "" {
+ walletBalance = wallet.Balance
+ }
}
ownerName := ""
if owner != nil {
diff --git a/internal/logic/admin/workbench.go b/internal/logic/admin/workbench.go
new file mode 100644
index 0000000..6161299
--- /dev/null
+++ b/internal/logic/admin/workbench.go
@@ -0,0 +1,252 @@
+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 分视为骤降
+ 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 字段。
+// 工作台跨域聚合,必须按权限逐条过滤 —— 只有 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"`
+}
+
+// ============================================================
+// 对外接口
+// ============================================================
+
+// 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{}
+ for _, it := range items {
+ res.Total++
+ if it.Severity == v1.WorkbenchSeverityP0 {
+ res.Urgent++
+ }
+ }
+ 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
+}
diff --git a/internal/logic/admin/workbench_collectors.go b/internal/logic/admin/workbench_collectors.go
new file mode 100644
index 0000000..ca7e166
--- /dev/null
+++ b/internal/logic/admin/workbench_collectors.go
@@ -0,0 +1,969 @@
+package admin
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "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/dispatchadapter"
+ "github.com/qianfree/team-api/internal/logic/billing"
+ "github.com/qianfree/team-api/internal/logic/common"
+)
+
+// ============================================================
+// 服务可用性(channel:view)
+// ============================================================
+
+// wbCollectAvailability 渠道与模型可用性。
+// 返回模型可用渠道速览、正在熔断列表,以及派生出的待办。
+func (s *sAdmin) wbCollectAvailability(ctx context.Context) ([]v1.WorkbenchModelAvail, []v1.WorkbenchBreaker, []wbItem) {
+ items := make([]wbItem, 0, 8)
+
+ // --- 目录快照:模型 → 可用/总渠道数 + 熔断明细 ---
+ var avail map[string]dispatchadapter.ModelAvail
+ if cat := dispatchadapter.CatalogInstance(); cat != nil {
+ avail = cat.ModelAvailability()
+ }
+
+ models := make([]v1.WorkbenchModelAvail, 0, len(avail))
+ breakers := make([]v1.WorkbenchBreaker, 0, 8)
+ for model, av := range avail {
+ models = append(models, v1.WorkbenchModelAvail{
+ Model: model, Available: av.Available, Total: av.Total,
+ })
+ for _, b := range av.Breaking {
+ breakers = append(breakers, v1.WorkbenchBreaker{
+ ChannelId: b.ChannelID,
+ ChannelName: b.ChannelName,
+ Model: b.Model,
+ ChannelLevel: b.ChannelLevel,
+ HalfOpen: b.HalfOpen,
+ })
+ }
+
+ // P0:配了渠道但一条都用不了。
+ // 这是最容易被大盘掩盖的静默故障 —— 单个模型全挂时,平台总请求量可能只掉几个
+ // 百分点,成功率曲线也被其他健康模型摊薄,但该模型的客户已经 100% 失败了。
+ if av.Total > 0 && av.Available == 0 {
+ items = append(items, wbNew(
+ "model_no_channel:"+model,
+ v1.WorkbenchSeverityP0, v1.WorkbenchDomainAvailability, "channel:view",
+ fmt.Sprintf("模型 %s 零可用渠道", model),
+ fmt.Sprintf("承载该模型的 %d 个渠道全部处于熔断或不可用状态,该模型请求当前必然失败", av.Total),
+ "查看渠道", "AdminChannels", map[string]string{"status": "active"}, nil,
+ ))
+ }
+ }
+ // 排序保证响应稳定:map 遍历顺序随机会让前端列表每次刷新都跳动
+ sortModelAvail(models)
+ sortBreakers(breakers)
+
+ // P1:渠道级熔断(该渠道所有模型受影响,但还没到零可用)
+ chLevel := map[int64]string{}
+ for _, b := range breakers {
+ if b.ChannelLevel && !b.HalfOpen {
+ chLevel[b.ChannelId] = b.ChannelName
+ }
+ }
+ for id, name := range chLevel {
+ items = append(items, wbNew(
+ fmt.Sprintf("channel_breaker:%d", id),
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainAvailability, "channel:view",
+ fmt.Sprintf("渠道 %s 已熔断", name),
+ "该渠道全部模型已被调度器摘除,容量下降,恢复前流量会压到其余渠道",
+ "查看渠道", "AdminChannelDetail", map[string]string{"id": fmt.Sprint(id)}, nil,
+ ))
+ }
+
+ items = append(items, s.wbChannelKeyIssues(ctx)...)
+ items = append(items, s.wbOAuthExpiring(ctx)...)
+ items = append(items, s.wbHealthDrop(ctx)...)
+
+ return models, breakers, items
+}
+
+// wbChannelKeyIssues 渠道 Key 失效 / 额度耗尽。
+// Key 挂掉时渠道整体仍是 active,容量却已悄悄下降 —— 列表页看不出来。
+func (s *sAdmin) wbChannelKeyIssues(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT k.id, k.channel_id, k.name, k.status, k.last_error, c.name AS channel_name,
+ (SELECT COUNT(*) FROM chn_channel_keys k2 WHERE k2.channel_id = k.channel_id) AS total_keys
+ FROM chn_channel_keys k
+ JOIN chn_channels c ON c.id = k.channel_id
+ WHERE k.status <> 'active' AND c.status = 'active'
+ ORDER BY k.updated_at DESC
+ LIMIT 20`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询异常渠道 Key 失败: %v", err)
+ return nil
+ }
+
+ items := make([]wbItem, 0, len(rows))
+ for _, r := range rows {
+ status := r["status"].String()
+ label := "已禁用"
+ if status == "exhausted" {
+ label = "额度耗尽"
+ }
+ total := r["total_keys"].Int()
+ desc := fmt.Sprintf("渠道「%s」的 Key「%s」%s", r["channel_name"].String(), r["name"].String(), label)
+ if total > 1 {
+ desc += fmt.Sprintf(",该渠道可用 Key %d/%d,容量下降", total-1, total)
+ } else {
+ desc += ",该渠道已无可用 Key"
+ }
+ if e := strings.TrimSpace(r["last_error"].String()); e != "" {
+ desc += ";最后错误:" + truncate(e, 120)
+ }
+
+ items = append(items, wbNew(
+ fmt.Sprintf("channel_key_invalid:%d", r["id"].Int64()),
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainAvailability, "channel:view",
+ fmt.Sprintf("%s Key %s", r["channel_name"].String(), label),
+ desc,
+ "查看渠道", "AdminChannelDetail",
+ map[string]string{"id": r["channel_id"].String()}, nil,
+ ))
+ }
+ return items
+}
+
+// wbOAuthExpiring OAuth 令牌临期。
+// 极其隐蔽:过期那一刻整条渠道直接不可用,且没有任何渐进信号。
+func (s *sAdmin) wbOAuthExpiring(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT k.id, k.channel_id, k.name, k.token_expires_at, c.name AS channel_name
+ FROM chn_channel_keys k
+ JOIN chn_channels c ON c.id = k.channel_id
+ WHERE k.key_type = 'oauth'
+ AND c.status = 'active'
+ AND k.token_expires_at IS NOT NULL
+ AND k.token_expires_at <= ?
+ ORDER BY k.token_expires_at ASC
+ LIMIT 20`, gtime.Now().Add(wbOAuthExpiryHours*time.Hour))
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询 OAuth 临期令牌失败: %v", err)
+ return nil
+ }
+
+ items := make([]wbItem, 0, len(rows))
+ for _, r := range rows {
+ expAt := r["token_expires_at"].GTime()
+ expired := expAt != nil && expAt.Before(gtime.Now())
+
+ sev := v1.WorkbenchSeverityP1
+ title := fmt.Sprintf("%s OAuth 令牌即将过期", r["channel_name"].String())
+ desc := fmt.Sprintf("Key「%s」将于 %s 过期,过期后该渠道立即不可用",
+ r["name"].String(), fmtTime(expAt))
+ if expired {
+ // 已经过期就不是预警而是故障了
+ sev = v1.WorkbenchSeverityP0
+ title = fmt.Sprintf("%s OAuth 令牌已过期", r["channel_name"].String())
+ desc = fmt.Sprintf("Key「%s」已于 %s 过期,该渠道当前不可用",
+ r["name"].String(), fmtTime(expAt))
+ }
+
+ items = append(items, wbNew(
+ fmt.Sprintf("channel_oauth_expiring:%d", r["id"].Int64()),
+ sev, v1.WorkbenchDomainAvailability, "channel:view",
+ title, desc,
+ "重新授权", "AdminChannelDetail",
+ map[string]string{"id": r["channel_id"].String()}, nil,
+ ))
+ }
+ return items
+}
+
+// wbHealthDrop 健康分骤降。
+// 判据是「相对自己 1 小时前的跌幅」而非绝对分值 —— 一个长期 60 分的渠道是已知状态,
+// 而 92 分跌到 61 分是正在发生的事故,绝对阈值分不出这两者。
+func (s *sAdmin) wbHealthDrop(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `WITH latest AS (
+ SELECT DISTINCT ON (channel_id) channel_id, health_score, snapshot_at
+ FROM chn_health_snapshots
+ WHERE snapshot_at >= NOW() - INTERVAL '15 minutes'
+ ORDER BY channel_id, snapshot_at DESC
+ ), baseline AS (
+ SELECT DISTINCT ON (channel_id) channel_id, health_score
+ FROM chn_health_snapshots
+ WHERE snapshot_at BETWEEN NOW() - INTERVAL '75 minutes' AND NOW() - INTERVAL '45 minutes'
+ ORDER BY channel_id, snapshot_at DESC
+ )
+ SELECT l.channel_id, c.name AS channel_name,
+ ROUND(l.health_score)::int AS now_score,
+ ROUND(b.health_score)::int AS prev_score,
+ l.snapshot_at
+ FROM latest l
+ JOIN baseline b ON b.channel_id = l.channel_id
+ JOIN chn_channels c ON c.id = l.channel_id
+ WHERE c.status = 'active'
+ AND b.health_score - l.health_score >= ?
+ ORDER BY (b.health_score - l.health_score) DESC
+ LIMIT 10`, wbHealthDropPoints)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询健康分骤降失败: %v", err)
+ return nil
+ }
+
+ items := make([]wbItem, 0, len(rows))
+ for _, r := range rows {
+ items = append(items, wbNew(
+ fmt.Sprintf("channel_health_drop:%d", r["channel_id"].Int64()),
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainAvailability, "channel:view",
+ fmt.Sprintf("%s 健康分骤降", r["channel_name"].String()),
+ fmt.Sprintf("1 小时内从 %d 分跌至 %d 分,调度权重已自动下调,尚未触发熔断",
+ r["prev_score"].Int(), r["now_score"].Int()),
+ "查看健康趋势", "AdminChannelDetail",
+ map[string]string{"id": r["channel_id"].String()},
+ r["snapshot_at"].GTime(),
+ ))
+ }
+ return items
+}
+
+// ============================================================
+// 资金安全(billing:view / order:view)
+// ============================================================
+
+func (s *sAdmin) wbCollectMoney(ctx context.Context) []wbItem {
+ items := make([]wbItem, 0, 8)
+ items = append(items, s.wbWalletDrift(ctx)...)
+ items = append(items, s.wbFrozenStale(ctx)...)
+ items = append(items, s.wbOrderUnfulfilled(ctx)...)
+ items = append(items, s.wbLowBalanceTenants(ctx)...)
+ return items
+}
+
+// wbWalletDrift 钱包 Redis↔DB 物化偏差。
+//
+// 按架构约定 Redis 是余额唯一实时权威,DB 只是每 5s 物化一次的滞后副本。
+// 正常情况下二者差异很小;持续放大的偏差意味着物化器停摆或 Lua 与账本不一致,
+// 此时 DB 余额不可用于对账,且问题会随时间累积 —— 属于必须立刻知道的一类。
+func (s *sAdmin) wbWalletDrift(ctx context.Context) []wbItem {
+ // 只抽查有余额的活跃钱包,避免全表逐个读 Redis
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT w.tenant_id, w.balance, t.name AS tenant_name
+ FROM bil_wallets w
+ JOIN tnt_tenants t ON t.id = w.tenant_id
+ WHERE w.balance > 0 AND t.status IN ('active', 'trial')
+ ORDER BY w.balance DESC
+ LIMIT 30`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询钱包列表失败: %v", err)
+ return nil
+ }
+
+ var (
+ worstTenant string
+ worstDrift float64
+ driftCount int
+ )
+ for _, r := range rows {
+ tenantID := r["tenant_id"].Int64()
+ dbBalance := r["balance"].Float64()
+
+ info, err := billing.GetWallet(ctx, tenantID)
+ if err != nil || info == nil {
+ continue
+ }
+ drift := info.Balance - dbBalance
+ if drift < 0 {
+ drift = -drift
+ }
+ // 相对阈值 + 绝对下限:小额账户的分币级差异不值得报警
+ if dbBalance > 0 && drift/dbBalance > wbWalletDriftRatio && drift > 1 {
+ driftCount++
+ if drift > worstDrift {
+ worstDrift = drift
+ worstTenant = r["tenant_name"].String()
+ }
+ }
+ }
+
+ if driftCount == 0 {
+ return nil
+ }
+ return []wbItem{wbNew(
+ "wallet_drift",
+ v1.WorkbenchSeverityP0, v1.WorkbenchDomainMoney, "billing:view",
+ "钱包 Redis 与 DB 物化余额偏差",
+ fmt.Sprintf("抽查发现 %d 个钱包偏差超阈值,最大偏差 %.2f(租户「%s」)。"+
+ "DB 余额由物化器每 5 秒覆盖,持续偏差说明物化器可能停摆,当前 DB 余额不可用于对账",
+ driftCount, worstDrift, worstTenant),
+ "查看定时任务", "AdminCronJobs", nil, nil,
+ )}
+}
+
+// wbFrozenStale 长时间未释放的冻结。
+// 客户余额被凭空锁住,是客服工单的高发源,且往往是异步任务未回收的信号。
+func (s *sAdmin) wbFrozenStale(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT w.tenant_id, t.name AS tenant_name
+ FROM bil_wallets w
+ JOIN tnt_tenants t ON t.id = w.tenant_id
+ WHERE w.frozen_balance > 0
+ ORDER BY w.frozen_balance DESC
+ LIMIT 20`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询冻结钱包失败: %v", err)
+ return nil
+ }
+
+ items := make([]wbItem, 0, 4)
+ for _, r := range rows {
+ tenantID := r["tenant_id"].Int64()
+ frozen, err := billing.GetFrozenItems(ctx, tenantID)
+ if err != nil {
+ continue
+ }
+ var (
+ staleCount int
+ staleTotal float64
+ )
+ // FrozenItem.CreatedAt 是 Unix 秒(预扣 Lua 写入),与既有冻结明细接口同口径
+ staleBefore := time.Now().Add(-wbFrozenStaleHours * time.Hour).Unix()
+ for _, f := range frozen {
+ if f.CreatedAt > 0 && f.CreatedAt <= staleBefore {
+ staleCount++
+ staleTotal += f.Amount
+ }
+ }
+ if staleCount == 0 {
+ continue
+ }
+ items = append(items, wbNew(
+ fmt.Sprintf("frozen_stale:%d", tenantID),
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "billing:view",
+ fmt.Sprintf("租户「%s」%d 笔冻结超时未释放", r["tenant_name"].String(), staleCount),
+ fmt.Sprintf("合计 %.6f 被锁定超过 %d 小时,客户可用余额受影响,通常是异步任务未回收",
+ staleTotal, wbFrozenStaleHours),
+ "查看冻结明细", "AdminTenantDetail",
+ map[string]string{"id": fmt.Sprint(tenantID), "tab": "wallet"}, nil,
+ ))
+ }
+ return items
+}
+
+// wbOrderUnfulfilled 已支付未履约的卡单。真金白银,客户会追。
+func (s *sAdmin) wbOrderUnfulfilled(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT o.id, o.order_no, o.final_amount, o.paid_at, o.order_type, t.name AS tenant_name
+ FROM ord_orders o
+ LEFT JOIN tnt_tenants t ON t.id = o.tenant_id
+ WHERE o.status = 'paid'
+ AND o.fulfilled_at IS NULL
+ AND o.paid_at IS NOT NULL
+ AND o.paid_at <= ?
+ ORDER BY o.paid_at ASC
+ LIMIT 20`, gtime.Now().Add(-wbOrderUnfulfilledMin*time.Minute))
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询卡单失败: %v", err)
+ return nil
+ }
+
+ items := make([]wbItem, 0, len(rows))
+ for _, r := range rows {
+ paidAt := r["paid_at"].GTime()
+ items = append(items, wbNew(
+ "order_unfulfilled:"+r["order_no"].String(),
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "order:view",
+ fmt.Sprintf("订单 %s 已支付未履约", r["order_no"].String()),
+ fmt.Sprintf("租户「%s」支付 ¥%.2f 于 %s,至今未履约,客户可能已在催",
+ r["tenant_name"].String(), r["final_amount"].Float64(), fmtTime(paidAt)),
+ "处理订单", "AdminOrders",
+ map[string]string{"order_no": r["order_no"].String()}, paidAt,
+ ))
+ }
+ return items
+}
+
+// wbLowBalanceTenants 余额低于预警阈值的租户。
+// 一个信号两个用途:流失预警 + 催充窗口。聚合成一条,不逐租户刷屏。
+func (s *sAdmin) wbLowBalanceTenants(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt, COALESCE(SUM(w.balance - w.frozen_balance), 0) AS available
+ FROM bil_wallets w
+ JOIN tnt_tenants t ON t.id = w.tenant_id
+ WHERE t.status IN ('active', 'trial')
+ AND w.warning_threshold > 0
+ AND (w.balance - w.frozen_balance) < w.warning_threshold`)
+ if err != nil || len(rows) == 0 {
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询低余额租户失败: %v", err)
+ }
+ return nil
+ }
+
+ cnt := rows[0]["cnt"].Int()
+ if cnt == 0 {
+ return nil
+ }
+ return []wbItem{wbNew(
+ "tenant_low_balance",
+ v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "billing:view",
+ fmt.Sprintf("%d 个租户余额低于预警阈值", cnt),
+ fmt.Sprintf("合计可用余额 %.6f,既是服务中断风险,也是催充与流失干预的窗口",
+ rows[0]["available"].Float64()),
+ "查看租户", "AdminTenants", nil, nil,
+ )}
+}
+
+// ============================================================
+// 客户运营(support:view / plan:view)
+// ============================================================
+
+func (s *sAdmin) wbCollectCustomer(ctx context.Context) []wbItem {
+ items := make([]wbItem, 0, 8)
+ items = append(items, s.wbTicketIssues(ctx)...)
+ items = append(items, s.wbFeedbackPending(ctx)...)
+ items = append(items, s.wbPlanExpiring(ctx)...)
+ return items
+}
+
+// wbTicketIssues 工单:只报「超 SLA 未首响」和「未分配」两类。
+// 刻意不报工单总数 —— 那是个永远不为零的数字,放进待办只会制造永久红点。
+func (s *sAdmin) wbTicketIssues(ctx context.Context) []wbItem {
+ items := make([]wbItem, 0, 4)
+
+ // 超 SLA 未首次响应:pending 且创建超过 SLA 时长
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT k.id, k.title, k.urgency, k.created_at, t.name AS tenant_name
+ FROM spt_tickets k
+ LEFT JOIN tnt_tenants t ON t.id = k.tenant_id
+ WHERE k.status = 'pending'
+ AND k.created_at <= ?
+ ORDER BY k.created_at ASC
+ LIMIT 10`, gtime.Now().Add(-wbTicketSLAHours*time.Hour))
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询超时工单失败: %v", err)
+ } else {
+ for _, r := range rows {
+ createdAt := r["created_at"].GTime()
+ sev := v1.WorkbenchSeverityP1
+ if u := r["urgency"].String(); u == "urgent" {
+ sev = v1.WorkbenchSeverityP0
+ }
+ items = append(items, wbNew(
+ fmt.Sprintf("ticket_sla_breach:%d", r["id"].Int64()),
+ sev, v1.WorkbenchDomainCustomer, "support:view",
+ fmt.Sprintf("工单「%s」超 SLA 未首次响应", truncate(r["title"].String(), 40)),
+ fmt.Sprintf("提交方「%s」,已等待 %s(SLA %d 小时)",
+ r["tenant_name"].String(), humanizeSince(createdAt), wbTicketSLAHours),
+ "去回复", "AdminTickets",
+ map[string]string{"id": r["id"].String()}, createdAt,
+ ))
+ }
+ }
+
+ // 未分配工单:聚合成一条
+ cntRows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt FROM spt_tickets
+ WHERE status IN ('pending', 'reopened')
+ AND (assigned_admin_id IS NULL OR assigned_admin_id = 0)`)
+ if err == nil && len(cntRows) > 0 {
+ if n := cntRows[0]["cnt"].Int(); n > 0 {
+ items = append(items, wbNew(
+ "ticket_unassigned",
+ v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "support:view",
+ fmt.Sprintf("%d 个工单未分配处理人", n),
+ "无人认领的工单容易在交接中被漏掉,建议指派到人",
+ "去分配", "AdminTickets", nil, nil,
+ ))
+ }
+ }
+ return items
+}
+
+// wbFeedbackPending 待处理反馈(聚合一条 + 最长等待时间)。
+func (s *sAdmin) wbFeedbackPending(ctx context.Context) []wbItem {
+ rows, 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 || len(rows) == 0 {
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询待处理反馈失败: %v", err)
+ }
+ return nil
+ }
+ cnt := rows[0]["cnt"].Int()
+ if cnt == 0 {
+ return nil
+ }
+ oldest := rows[0]["oldest"].GTime()
+
+ return []wbItem{wbNew(
+ "feedback_pending",
+ v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "support:view",
+ fmt.Sprintf("%d 条用户反馈待处理", cnt),
+ fmt.Sprintf("最早 1 条已等待 %s", humanizeSince(oldest)),
+ "去处理", "AdminFeedback", nil, nil,
+ )}
+}
+
+// wbPlanExpiring 套餐即将到期且未开启自动续费 —— 续费转化窗口。
+func (s *sAdmin) wbPlanExpiring(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt
+ FROM pln_tenant_plans
+ WHERE status = 'active'
+ AND auto_renew = false
+ AND end_at IS NOT NULL
+ AND end_at BETWEEN NOW() AND ?`, gtime.Now().AddDate(0, 0, wbPlanExpiringDays))
+ if err != nil || len(rows) == 0 {
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询到期套餐失败: %v", err)
+ }
+ return nil
+ }
+ cnt := rows[0]["cnt"].Int()
+ if cnt == 0 {
+ return nil
+ }
+ return []wbItem{wbNew(
+ "plan_expiring",
+ v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "plan:view",
+ fmt.Sprintf("%d 个租户套餐 %d 天内到期", cnt, wbPlanExpiringDays),
+ "均未开启自动续费,到期即停服,建议提前触达",
+ "查看租户", "AdminTenants", nil, nil,
+ )}
+}
+
+// ============================================================
+// 系统与安全(system:view / monitor:view / audit:view)
+// ============================================================
+
+func (s *sAdmin) wbCollectSystem(ctx context.Context) []wbItem {
+ items := make([]wbItem, 0, 8)
+ items = append(items, s.wbCronStalled(ctx)...)
+ items = append(items, s.wbEmailFailures(ctx)...)
+ items = append(items, s.wbAlertUnacked(ctx)...)
+ items = append(items, s.wbErrorLogsUnresolved(ctx)...)
+ items = append(items, s.wbNewIPLogin(ctx)...)
+ return items
+}
+
+// wbCronStalled 定时任务失败或停摆。
+// 资金相关任务(预扣清扫、钱包物化)挂掉会直接导致资损,且完全无声。
+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
+ 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
+ }
+
+ var desc string
+ switch {
+ case failed:
+ desc = "最近一次执行失败"
+ if e := strings.TrimSpace(r["last_error_message"].String()); e != "" {
+ desc += ":" + truncate(e, 150)
+ }
+ case startedAt == nil:
+ desc = "自服务启动以来从未执行过,调度器可能未正常注册该任务"
+ default:
+ desc = fmt.Sprintf("正常约 %s 执行一次,已 %s 未执行,任务可能已停摆",
+ humanizeDuration(interval), humanizeSince(startedAt))
+ }
+ if isMoneyCriticalJob(name) {
+ desc += "。该任务涉及资金结算,停摆会持续累积差错"
+ }
+
+ items = append(items, wbNew(
+ "cron_stalled:"+name,
+ sev, v1.WorkbenchDomainSystem, "system:view",
+ fmt.Sprintf("定时任务 %s 异常", name),
+ desc,
+ "查看定时任务", "AdminCronJobs", nil, startedAt,
+ ))
+ }
+ 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)
+ for _, kw := range []string{"prededuct", "wallet", "materiali", "settle", "billing", "refund"} {
+ if strings.Contains(n, kw) {
+ return true
+ }
+ }
+ return false
+}
+
+// wbEmailFailures 邮件发送失败。
+// 隐蔽度极高:验证码发不出去等于新用户注册与找回密码链路整条中断,
+// 而受影响的人根本没有渠道告诉你。
+func (s *sAdmin) wbEmailFailures(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt,
+ COUNT(*) FILTER (WHERE template_code ILIKE '%verify%' OR template_code ILIKE '%code%') AS verify_cnt,
+ MAX(error_message) AS sample_error
+ FROM ntf_send_log
+ WHERE channel = 'email'
+ AND status = 'failed'
+ AND created_at >= NOW() - INTERVAL '1 hour'`)
+ if err != nil || len(rows) == 0 {
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询邮件失败记录失败: %v", err)
+ }
+ return nil
+ }
+
+ cnt := rows[0]["cnt"].Int()
+ if cnt < wbEmailFailThreshold {
+ return nil
+ }
+
+ desc := fmt.Sprintf("近 1 小时内 %d 封邮件发送失败", cnt)
+ if vc := rows[0]["verify_cnt"].Int(); vc > 0 {
+ desc += fmt.Sprintf(",其中 %d 封为验证码类 —— 新用户注册与找回密码链路已实际中断", vc)
+ }
+ if e := strings.TrimSpace(rows[0]["sample_error"].String()); e != "" {
+ desc += ";样本错误:" + truncate(e, 120)
+ }
+
+ return []wbItem{wbNew(
+ "email_failures",
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainSystem, "system:view",
+ "邮件发送连续失败",
+ desc,
+ "查看发送记录", "AdminEmailLogs", map[string]string{"status": "failed"}, nil,
+ )}
+}
+
+// wbAlertUnacked 未确认告警(聚合一条)。
+func (s *sAdmin) wbAlertUnacked(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt,
+ COUNT(*) FILTER (WHERE level = 'critical') AS critical_cnt,
+ MIN(created_at) AS oldest
+ FROM ops_alert_events
+ WHERE status = 'firing'`)
+ if err != nil || len(rows) == 0 {
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询未确认告警失败: %v", err)
+ }
+ return nil
+ }
+ cnt := rows[0]["cnt"].Int()
+ if cnt == 0 {
+ return nil
+ }
+
+ criticalCnt := rows[0]["critical_cnt"].Int()
+ sev := v1.WorkbenchSeverityP2
+ if criticalCnt > 0 {
+ sev = v1.WorkbenchSeverityP1
+ }
+ desc := fmt.Sprintf("最早 1 条触发于 %s", humanizeSince(rows[0]["oldest"].GTime()))
+ if criticalCnt > 0 {
+ desc = fmt.Sprintf("其中 %d 条为严重级别;", criticalCnt) + desc
+ }
+
+ return []wbItem{wbNew(
+ "alert_unack",
+ sev, v1.WorkbenchDomainSystem, "monitor:view",
+ fmt.Sprintf("%d 条告警事件未确认", cnt),
+ desc,
+ "去确认", "AdminAlertEvents", map[string]string{"status": "firing"}, nil,
+ )}
+}
+
+// wbErrorLogsUnresolved 未处理系统错误日志(聚合一条)。
+func (s *sAdmin) wbErrorLogsUnresolved(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt, COUNT(DISTINCT error_message) AS distinct_cnt
+ FROM sys_error_logs
+ WHERE resolved = false
+ AND created_at >= NOW() - INTERVAL '7 days'`)
+ if err != nil || len(rows) == 0 {
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询未处理错误日志失败: %v", err)
+ }
+ return nil
+ }
+ cnt := rows[0]["cnt"].Int()
+ if cnt == 0 {
+ return nil
+ }
+
+ desc := "近 7 天内产生"
+ if d := rows[0]["distinct_cnt"].Int(); d > 0 && cnt > d {
+ desc += fmt.Sprintf(",去重后仅 %d 种,建议按类型合并排查", d)
+ }
+
+ return []wbItem{wbNew(
+ "error_log_unresolved",
+ v1.WorkbenchSeverityP2, v1.WorkbenchDomainSystem, "monitor:view",
+ fmt.Sprintf("%d 条系统错误日志未处理", cnt),
+ desc,
+ "查看错误日志", "AdminErrorLogs", nil, nil,
+ )}
+}
+
+// wbNewIPLogin 管理员从新 IP 登录成功。
+// 管理后台账号是整个平台的最高权限,异地登录必须让人看见并确认。
+func (s *sAdmin) wbNewIPLogin(ctx context.Context) []wbItem {
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT h.id, h.user_id, h.ip_address, h.location, h.created_at, u.username
+ FROM aud_login_history h
+ LEFT JOIN sys_admin_users u ON u.id = h.user_id
+ WHERE h.user_type = 'admin'
+ AND h.success = true
+ AND h.is_new_device = true
+ AND h.created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY h.created_at DESC
+ LIMIT 5`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询异常登录失败: %v", err)
+ return nil
+ }
+
+ items := make([]wbItem, 0, len(rows))
+ for _, r := range rows {
+ createdAt := r["created_at"].GTime()
+ loc := strings.TrimSpace(r["location"].String())
+ if loc == "" {
+ loc = "未知归属地"
+ }
+ items = append(items, wbNew(
+ fmt.Sprintf("admin_new_ip_login:%d", r["id"].Int64()),
+ v1.WorkbenchSeverityP1, v1.WorkbenchDomainSystem, "audit:view",
+ fmt.Sprintf("管理员 %s 从新设备登录", r["username"].String()),
+ fmt.Sprintf("IP %s(%s),此前无该设备记录,请确认是否本人操作",
+ maskIP(r["ip_address"].String()), loc),
+ "查看登录历史", "AdminLoginHistory", nil, createdAt,
+ ))
+ }
+ return items
+}
+
+// ============================================================
+// 首屏关键数字
+// ============================================================
+
+// wbCollectMetrics 首屏三个数字 + 待办计数由上层补。
+//
+// 收入对比刻意用「昨日同期」而非「昨日全天」:全天对比在每天早晨永远是 -80%,
+// 是个看了就要在脑子里做一次除法的假信号。
+func (s *sAdmin) wbCollectMetrics(ctx context.Context) []v1.WorkbenchMetric {
+ metrics := make([]v1.WorkbenchMetric, 0, 3)
+
+ // --- 今日收入 vs 昨日同期 ---
+ var todayCost, yesterdayCost float64
+ rows, err := g.DB().Ctx(ctx).Query(ctx,
+ `SELECT
+ COALESCE(SUM(total_cost) FILTER (WHERE created_at >= date_trunc('day', NOW())), 0) AS today_cost,
+ COALESCE(SUM(total_cost) FILTER (
+ WHERE created_at >= date_trunc('day', NOW()) - INTERVAL '1 day'
+ AND created_at < date_trunc('day', NOW()) - INTERVAL '1 day' + (NOW() - date_trunc('day', NOW()))
+ ), 0) AS yesterday_cost
+ FROM bil_usage_logs
+ WHERE created_at >= date_trunc('day', NOW()) - INTERVAL '1 day'`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询收入指标失败: %v", err)
+ } else if len(rows) > 0 {
+ todayCost = rows[0]["today_cost"].Float64()
+ yesterdayCost = rows[0]["yesterday_cost"].Float64()
+ }
+
+ revenueMetric := v1.WorkbenchMetric{
+ Key: "today_revenue", Label: "今日收入", Value: todayCost,
+ Unit: "money", Sub: "较昨日同期",
+ }
+ if yesterdayCost > 0 {
+ growth := (todayCost - yesterdayCost) / yesterdayCost * 100
+ revenueMetric.Growth = &growth
+ }
+ metrics = append(metrics, revenueMetric)
+
+ // --- 实时错误率(近 5 分钟)---
+ // 刻意不用「今日成功率」:那个数被一整天的历史稀释,正在发生的故障根本顶不动它。
+ var errRate float64
+ rows, err = g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE status <> 'success') AS errors
+ FROM bil_usage_logs
+ WHERE created_at >= NOW() - INTERVAL '5 minutes'`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询实时错误率失败: %v", err)
+ } else if len(rows) > 0 {
+ if total := rows[0]["total"].Float64(); total > 0 {
+ errRate = rows[0]["errors"].Float64() / total * 100
+ }
+ }
+ metrics = append(metrics, v1.WorkbenchMetric{
+ Key: "error_rate_5m", Label: "实时错误率", Value: round2(errRate),
+ Unit: "percent", Sub: "近 5 分钟",
+ })
+
+ // --- 余额告警租户数 ---
+ var lowBalance float64
+ rows, err = g.DB().Ctx(ctx).Query(ctx,
+ `SELECT COUNT(*) AS cnt
+ FROM bil_wallets w
+ JOIN tnt_tenants t ON t.id = w.tenant_id
+ WHERE t.status IN ('active', 'trial')
+ AND w.warning_threshold > 0
+ AND (w.balance - w.frozen_balance) < w.warning_threshold`)
+ if err != nil {
+ g.Log().Warningf(ctx, "workbench: 查询余额告警租户失败: %v", err)
+ } else if len(rows) > 0 {
+ lowBalance = rows[0]["cnt"].Float64()
+ }
+ metrics = append(metrics, v1.WorkbenchMetric{
+ Key: "low_balance_tenants", Label: "余额告警租户", Value: lowBalance,
+ Unit: "count", Sub: "低于预警阈值",
+ })
+
+ return metrics
+}
+
+// ============================================================
+// 小工具
+// ============================================================
+
+func truncate(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n]) + "…"
+}
+
+func round2(f float64) float64 {
+ return float64(int64(f*100+0.5)) / 100
+}
+
+func fmtTime(t *gtime.Time) string {
+ if t == nil {
+ return "未知时间"
+ }
+ return t.Format("Y-m-d H:i")
+}
+
+// humanizeSince 把时间差写成人话。工作台上「3 小时 12 分」比时间戳更能驱动行动。
+func humanizeSince(t *gtime.Time) string {
+ if t == nil {
+ return "未知时长"
+ }
+ return humanizeDuration(time.Since(t.Time))
+}
+
+// humanizeDuration 把时长写成人话(分钟/小时/天)。
+func humanizeDuration(d time.Duration) string {
+ if d < time.Minute {
+ return "不到 1 分钟"
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%d 分钟", int(d.Minutes()))
+ }
+ if d < 24*time.Hour {
+ h := int(d.Hours())
+ m := int(d.Minutes()) - h*60
+ if m == 0 {
+ return fmt.Sprintf("%d 小时", h)
+ }
+ return fmt.Sprintf("%d 小时 %d 分", h, m)
+ }
+ return fmt.Sprintf("%d 天", int(d.Hours()/24))
+}
+
+// maskIP 打码 IP 中间两段,兼顾可辨识与最小暴露。
+func maskIP(ip string) string {
+ parts := strings.Split(ip, ".")
+ if len(parts) != 4 {
+ return ip
+ }
+ return parts[0] + ".**.**." + parts[3]
+}
+
+func sortModelAvail(list []v1.WorkbenchModelAvail) {
+ sortSlice(len(list), func(i, j int) bool {
+ // 零可用的排最前,其次按缺口比例,最后按名字保证确定性
+ ai := list[i].Available == 0 && list[i].Total > 0
+ aj := list[j].Available == 0 && list[j].Total > 0
+ if ai != aj {
+ return ai
+ }
+ gi := list[i].Total - list[i].Available
+ gj := list[j].Total - list[j].Available
+ if gi != gj {
+ return gi > gj
+ }
+ return list[i].Model < list[j].Model
+ }, func(i, j int) { list[i], list[j] = list[j], list[i] })
+}
+
+func sortBreakers(list []v1.WorkbenchBreaker) {
+ sortSlice(len(list), func(i, j int) bool {
+ if list[i].ChannelLevel != list[j].ChannelLevel {
+ return list[i].ChannelLevel
+ }
+ if list[i].ChannelName != list[j].ChannelName {
+ return list[i].ChannelName < list[j].ChannelName
+ }
+ return list[i].Model < list[j].Model
+ }, func(i, j int) { list[i], list[j] = list[j], list[i] })
+}
+
+// sortSlice 简易插入排序:工作台的列表规模都在几十条以内,
+// 不值得为此引入反射版 sort.Slice 的开销。
+func sortSlice(n int, less func(i, j int) bool, swap func(i, j int)) {
+ for i := 1; i < n; i++ {
+ for j := i; j > 0 && less(j, j-1); j-- {
+ swap(j, j-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/internal/logic/common/email.go b/internal/logic/common/email.go
index 454d9a3..52e41bc 100644
--- a/internal/logic/common/email.go
+++ b/internal/logic/common/email.go
@@ -4,11 +4,13 @@ import (
"bytes"
"context"
"crypto/tls"
- "github.com/qianfree/team-api/internal/dao"
"html/template"
+ "net/mail"
"strings"
"time"
+ "github.com/qianfree/team-api/internal/dao"
+
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
@@ -23,9 +25,13 @@ type EmailConfig struct {
Username string
Password string
From string
+ FromName string // 发件人显示名,收件人邮件列表里看到的名字;为空时只显示邮箱地址
UseTLS bool
}
+// emailMaxAttempts 单封邮件的最大投递尝试次数(含首次),失败间隔 2s / 4s。
+const emailMaxAttempts = 3
+
// BasicEmailSender provides email sending capability with:
// - SMTP connection
// - Template rendering (Go templates)
@@ -52,11 +58,12 @@ type EmailMessage struct {
UserID int64
}
-// Send sends an email message with retry logic.
+// Send sends an email message with retry logic (3 attempts, 2s/4s backoff).
+// 重试间隔可被 ctx 取消打断:调用方设了超时(如配置连通性测试)时不会被死等拖满。
func (s *BasicEmailSender) Send(ctx context.Context, msg *EmailMessage) error {
var lastErr error
- for attempt := 0; attempt < 3; attempt++ {
+ for attempt := 0; attempt < emailMaxAttempts; attempt++ {
lastErr = s.sendWithRetry(ctx, msg, attempt)
if lastErr == nil {
// Log success
@@ -64,35 +71,49 @@ func (s *BasicEmailSender) Send(ctx context.Context, msg *EmailMessage) error {
return nil
}
- if attempt < 2 {
- time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
+ if attempt < emailMaxAttempts-1 {
+ if err := sleepCtx(ctx, time.Duration(attempt+1)*2*time.Second); err != nil {
+ // ctx 已取消/超时:不再重试,按失败记录当前已知错误
+ s.logSend(ctx, msg, "failed", lastErr, attempt)
+ return lastErr
+ }
}
}
// Log failure
- s.logSend(ctx, msg, "failed", lastErr, 2)
+ s.logSend(ctx, msg, "failed", lastErr, emailMaxAttempts-1)
return lastErr
}
+// SendOnce 只尝试一次投递,不做重试,同样写入 ntf_send_log。
+// 用于配置连通性测试等需要「快速失败并拿到真实 SMTP 错误」的场景——
+// 认证失败、端口不通这类错误重试三次也不会变好,只会让管理员多等十几秒。
+func (s *BasicEmailSender) SendOnce(ctx context.Context, msg *EmailMessage) error {
+ if err := s.sendWithRetry(ctx, msg, 0); err != nil {
+ s.logSend(ctx, msg, "failed", err, 0)
+ return err
+ }
+ s.logSend(ctx, msg, "sent", nil, 0)
+ return nil
+}
+
+// sleepCtx 等待 d 或直到 ctx 结束,ctx 先结束时返回其错误。
+func sleepCtx(ctx context.Context, d time.Duration) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
+
// SendTemplate sends an email using a template from ntf_templates.
func (s *BasicEmailSender) SendTemplate(ctx context.Context, to, templateCode string, variables map[string]any) error {
- // Load template
- var tpl *struct {
- Subject string `json:"subject"`
- BodyTemplate string `json:"body_template"`
- Channel string `json:"channel"`
- }
-
- err := dao.NtfTemplates.Ctx(ctx).
- Where("code", templateCode).
- Where("status", "active").
- Scan(&tpl)
+ tpl, err := loadNotificationTemplate(ctx, templateCode)
if err != nil {
- return gerror.Wrapf(err, "load template %s", templateCode)
- }
-
- if tpl.BodyTemplate == "" {
- return gerror.Newf("template %s not found", templateCode)
+ return err
}
// Render subject
@@ -121,7 +142,17 @@ func (s *BasicEmailSender) SendTemplate(ctx context.Context, to, templateCode st
// sendWithRetry performs the actual SMTP send.
func (s *BasicEmailSender) sendWithRetry(ctx context.Context, msg *EmailMessage, attempt int) error {
m := gomail.NewMessage()
- m.SetHeader("From", s.config.From)
+ // 发件人显示名:SetAddressHeader 会按 RFC 5322 拼成 "aifree" ,
+ // 名称含中文时自动做 RFC 2047 编码;FromName 为空时退化为纯地址。
+ // From 若已配成 "名称 <地址>" 形式,先拆出地址避免二次包装。
+ fromAddr, fromName := s.config.From, s.config.FromName
+ if parsed, err := mail.ParseAddress(fromAddr); err == nil {
+ fromAddr = parsed.Address
+ if fromName == "" {
+ fromName = parsed.Name
+ }
+ }
+ m.SetAddressHeader("From", fromAddr, fromName)
m.SetHeader("To", msg.To)
m.SetHeader("Subject", msg.Subject)
@@ -205,6 +236,7 @@ func EmailConfigFromOptions(ctx context.Context) (*EmailConfig, error) {
Username: Config().GetString(ctx, "email_smtp_username"),
Password: Config().GetString(ctx, "email_smtp_password"),
From: Config().GetString(ctx, "email_smtp_from"),
+ FromName: Config().GetString(ctx, "email_smtp_from_name"),
UseTLS: Config().GetBool(ctx, "email_smtp_tls"),
}
@@ -212,6 +244,11 @@ func EmailConfigFromOptions(ctx context.Context) (*EmailConfig, error) {
return nil, gerror.New("email SMTP config not set (email_smtp_host, email_smtp_from)")
}
+ // 未单独配置发件人名称时回落到站点名称,避免收件人只看到邮箱地址的本地部分
+ if cfg.FromName == "" {
+ cfg.FromName = Config().GetString(ctx, "site_name")
+ }
+
if cfg.Port == 0 {
cfg.Port = 587
}
diff --git a/internal/logic/common/email_test.go b/internal/logic/common/email_test.go
new file mode 100644
index 0000000..781ef89
--- /dev/null
+++ b/internal/logic/common/email_test.go
@@ -0,0 +1,67 @@
+package common
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+// sleepCtx 是重试退避可被取消的关键:调用方(如邮件配置连通性测试)设了超时后,
+// 不能再被 2s/4s 的固定退避拖满。
+func TestSleepCtx_WaitsFullDuration(t *testing.T) {
+ start := time.Now()
+ if err := sleepCtx(context.Background(), 30*time.Millisecond); err != nil {
+ t.Fatalf("sleepCtx() = %v, want nil", err)
+ }
+ if elapsed := time.Since(start); elapsed < 30*time.Millisecond {
+ t.Fatalf("sleepCtx 提前返回:耗时 %v,期望至少 30ms", elapsed)
+ }
+}
+
+func TestSleepCtx_ReturnsImmediatelyOnCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ start := time.Now()
+ err := sleepCtx(ctx, 5*time.Second)
+ elapsed := time.Since(start)
+
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("sleepCtx() = %v, want context.Canceled", err)
+ }
+ if elapsed > time.Second {
+ t.Fatalf("sleepCtx 未被取消打断:耗时 %v", elapsed)
+ }
+}
+
+func TestSleepCtx_InterruptedByDeadline(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ err := sleepCtx(ctx, 5*time.Second)
+ elapsed := time.Since(start)
+
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("sleepCtx() = %v, want context.DeadlineExceeded", err)
+ }
+ if elapsed > time.Second {
+ t.Fatalf("sleepCtx 未在超时后返回:耗时 %v", elapsed)
+ }
+}
+
+func TestIsValidEmail(t *testing.T) {
+ cases := map[string]bool{
+ "system@mail.aifree.com": true,
+ "a@b.co": true,
+ "no-at-sign.com": false,
+ "no-dot@localhost": false,
+ "": false,
+ }
+ for input, want := range cases {
+ if got := IsValidEmail(input); got != want {
+ t.Errorf("IsValidEmail(%q) = %v, want %v", input, got, want)
+ }
+ }
+}
diff --git a/internal/logic/common/notification.go b/internal/logic/common/notification.go
index df591cf..25d0477 100644
--- a/internal/logic/common/notification.go
+++ b/internal/logic/common/notification.go
@@ -12,8 +12,12 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
+ "golang.org/x/sync/errgroup"
)
+// broadcastEmailConcurrency 广播邮件的并发发送上限。
+const broadcastEmailConcurrency = 5
+
// NotificationEngine provides unified notification sending across email and in-app channels.
type NotificationEngine struct{}
@@ -141,7 +145,9 @@ func (e *NotificationEngine) SendBroadcast(ctx context.Context, tenantID int64,
}
// 4. Send email to all tenant members (async, best-effort)
- go e.broadcastEmails(ctx, tenantID, subject, body, templateCode, variables)
+ // WithoutCancel:广播在 HTTP 响应返回后仍在发信,直接用请求 ctx 会被取消,
+ // 导致成员查询与投递全部失败;脱离取消但保留 request_id 等链路值。
+ go e.broadcastEmails(context.WithoutCancel(ctx), tenantID, subject, body, templateCode, variables)
// 5. Log
e.logNotification(ctx, tenantID, 0, templateCode, subject, body)
@@ -228,16 +234,22 @@ type notificationTemplate struct {
// loadTemplate loads a notification template by code from ntf_templates.
func (e *NotificationEngine) loadTemplate(ctx context.Context, code string) (*notificationTemplate, error) {
+ return loadNotificationTemplate(ctx, code)
+}
+
+// loadNotificationTemplate 按 code 加载启用中的通知模板,供通知引擎与 BasicEmailSender.SendTemplate 共用。
+// 无记录时 Scan 返回 sql.ErrNoRows,统一转成可读的业务错误,避免把裸 ErrNoRows 抛给上层。
+func loadNotificationTemplate(ctx context.Context, code string) (*notificationTemplate, error) {
var tpl notificationTemplate
err := dao.NtfTemplates.Ctx(ctx).
Where("code", code).
Where("status", "active").
Scan(&tpl)
- if err != nil {
+ if err = IgnoreScanNoRows(err); err != nil {
return nil, err
}
if tpl.BodyTemplate == "" {
- return nil, gerror.Newf("template %s not found or inactive", code)
+ return nil, NewNotFoundError("通知模板 " + code)
}
return &tpl, nil
}
@@ -387,23 +399,32 @@ func (e *NotificationEngine) broadcastEmails(ctx context.Context, tenantID int64
return
}
+ // 有界并发:串行发送时总耗时 = 成员数 ×(SMTP 往返 + 最坏 6s 重试),
+ // 大租户会让这个 goroutine 挂很久;并发上限压在 broadcastEmailConcurrency 避免打爆 SMTP 服务商限流。
+ var eg errgroup.Group
+ eg.SetLimit(broadcastEmailConcurrency)
for _, m := range members {
if m.Email == "" || !IsValidEmail(m.Email) {
continue
}
- msg := &EmailMessage{
- To: m.Email,
- Subject: subject,
- BodyHTML: body,
- TemplateCode: templateCode,
- Variables: variables,
- TenantID: tenantID,
- UserID: m.ID,
- }
- if err := sender.Send(ctx, msg); err != nil {
- g.Log().Warningf(ctx, "notification engine broadcast: send to user %d failed: %v", m.ID, err)
- }
+ eg.Go(func() error {
+ msg := &EmailMessage{
+ To: m.Email,
+ Subject: subject,
+ BodyHTML: body,
+ TemplateCode: templateCode,
+ Variables: variables,
+ TenantID: tenantID,
+ UserID: m.ID,
+ }
+ // 单个成员失败不影响其余成员,仅告警(返回 nil 让 errgroup 继续)
+ if err := sender.Send(ctx, msg); err != nil {
+ g.Log().Warningf(ctx, "notification engine broadcast: send to user %d failed: %v", m.ID, err)
+ }
+ return nil
+ })
}
+ _ = eg.Wait()
}
// buildMetadata constructs a JSONB metadata value for the message.
diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go
index e4a07ae..f50620a 100644
--- a/internal/logic/common/settings_registry.go
+++ b/internal/logic/common/settings_registry.go
@@ -107,6 +107,8 @@ var Registry = []SettingDef{
Label: "SMTP 密码", Sensitive: true},
{Key: "email_smtp_from", Type: SettingTypeString, Default: "", Category: "email",
Label: "发件人地址", Description: "格式: noreply@example.com"},
+ {Key: "email_smtp_from_name", Type: SettingTypeString, Default: "", Category: "email",
+ Label: "发件人名称", Description: "收件人看到的发件人显示名,如 aifree;留空则使用站点名称"},
{Key: "email_smtp_tls", Type: SettingTypeBool, Default: "true", Category: "email",
Label: "启用 TLS", Description: "587/465 端口建议开启"},
diff --git a/internal/logic/common/verify_code.go b/internal/logic/common/verify_code.go
index 8836e16..4ec8214 100644
--- a/internal/logic/common/verify_code.go
+++ b/internal/logic/common/verify_code.go
@@ -79,13 +79,15 @@ func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) er
g.Log().Warningf(ctx, "failed to set verify code cooldown: %v", err)
}
+ // WithoutCancel:脱离请求取消(HTTP 响应先于邮件发出),但保留 request_id 等链路值,
+ // 便于在日志里把「发码请求」和「实际投递结果」串起来。
+ bgCtx := context.WithoutCancel(ctx)
go func() {
defer func() {
if r := recover(); r != nil {
- g.Log().Errorf(context.Background(), "send verify code email panic: %v", r)
+ g.Log().Errorf(bgCtx, "send verify code email panic: %v", r)
}
}()
- bgCtx := context.Background()
emailCfg, err := EmailConfigFromOptions(bgCtx)
if err != nil {
g.Log().Errorf(bgCtx, "load email config: %v", err)
diff --git a/internal/logic/monitor/alert_notify.go b/internal/logic/monitor/alert_notify.go
index ed384c4..9525647 100644
--- a/internal/logic/monitor/alert_notify.go
+++ b/internal/logic/monitor/alert_notify.go
@@ -95,15 +95,14 @@ func dispatchAlertNotifications(ctx context.Context, rule map[string]any, eventI
// sendAlertEmailToAdmins sends alert email to specified admin users.
func sendAlertEmailToAdmins(ctx context.Context, adminIDs []int64, subject, body string) {
- // Create email sender from config
- sender := common.NewEmailSender(&common.EmailConfig{
- Host: g.Cfg().MustGet(ctx, "email.smtp.host").String(),
- Port: g.Cfg().MustGet(ctx, "email.smtp.port").Int(),
- Username: g.Cfg().MustGet(ctx, "email.smtp.username").String(),
- Password: g.Cfg().MustGet(ctx, "email.smtp.password").String(),
- From: g.Cfg().MustGet(ctx, "email.smtp.from").String(),
- UseTLS: g.Cfg().MustGet(ctx, "email.smtp.port").Int() == 587 || g.Cfg().MustGet(ctx, "email.smtp.port").Int() == 465,
- })
+ // 与其余邮件链路统一走设置注册表(管理后台「邮件配置」),
+ // 而非 config.yaml 的 email.smtp.*(该配置段不存在,会读出空值导致告警邮件必然发送失败)
+ cfg, err := common.EmailConfigFromOptions(ctx)
+ if err != nil {
+ g.Log().Warningf(ctx, "send alert email: %v", err)
+ return
+ }
+ sender := common.NewEmailSender(cfg)
for _, adminID := range adminIDs {
type adminUser struct {
diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go
index 7fe06ab..c2a0e53 100644
--- a/internal/logic/task/async_polling.go
+++ b/internal/logic/task/async_polling.go
@@ -15,6 +15,7 @@ import (
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
+ "github.com/qianfree/team-api/relay/helper"
"github.com/qianfree/team-api/relay/taskchannel"
)
@@ -492,21 +493,22 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
}
record := &common.UsageRecord{
- TenantID: task.TenantID,
- UserID: task.UserID,
- ApiKeyID: task.ApiKeyID,
- ChannelID: task.ChannelID,
- ChannelName: channelName,
- ChannelType: channelType,
- ModelName: task.ModelName,
- RelayMode: int(constant.RelayModeVideoGenerations),
- RequestType: 3, // async
- LatencyMs: float64(latencyMs),
- IsStream: false,
- Success: success,
- RequestID: task.RequestID,
- Status: status,
- ErrorMessage: errMsg,
+ TenantID: task.TenantID,
+ UserID: task.UserID,
+ ApiKeyID: task.ApiKeyID,
+ ChannelID: task.ChannelID,
+ ChannelName: channelName,
+ ChannelType: channelType,
+ ModelName: task.ModelName,
+ RelayMode: int(constant.RelayModeVideoGenerations),
+ RequestType: 3, // async
+ LatencyMs: float64(latencyMs),
+ IsStream: false,
+ Success: success,
+ RequestID: task.RequestID,
+ Status: status,
+ // errMsg 可能来自上游 FailReason 原文,租户端用量日志可见,抹除其中的 URL/IP
+ ErrorMessage: helper.RedactMessage(errMsg),
PromptTokens: task.PromptTokens,
CompletionTokens: task.CompletionTokens,
TotalTokens: task.TotalTokens,
diff --git a/internal/logic/tenant/dashboard.go b/internal/logic/tenant/dashboard.go
index 576310b..b06c806 100644
--- a/internal/logic/tenant/dashboard.go
+++ b/internal/logic/tenant/dashboard.go
@@ -14,6 +14,9 @@ import (
"github.com/qianfree/team-api/internal/middleware"
)
+// budgetAlertThreshold 预算/额度使用率达到该比例即进入告警列表
+const budgetAlertThreshold = 0.8
+
// roundUSD 精确到小数点后 6 位,符合 CLAUDE.md 资金精度规范
func roundUSD(v float64) float64 {
return math.Round(v*1000000) / 1000000
@@ -98,6 +101,57 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
return nil, err
}
+ // 消费环比 + 无效支出(同表同租户同区间,合并为一条查询)。
+ //
+ // 上月只截取与本月已过时长相同的窗口,否则月初永远显示"环比大跌"。
+ // 三个边界统一格式化成与上面「本月统计」完全相同的字符串形式再传参:
+ // 本月起点必须与 monthStart 逐字一致,否则服务器时区与数据库会话时区不同时,
+ // 同屏的「本月消费」与 month.total_cost 会对不上。
+ const boundLayout = "2006-01-02 15:04:05"
+ curStartT := time.Date(now.Year(), time.Month(now.Month()), 1, 0, 0, 0, 0, now.Location())
+ curStartStr := monthStart + " 00:00:00"
+ prevStartStr := curStartT.AddDate(0, -1, 0).Format(boundLayout)
+ prevEndStr := curStartT.AddDate(0, -1, 0).Add(now.Time.Sub(curStartT)).Format(boundLayout)
+
+ var trendRow struct {
+ CurCost float64 `json:"cur_cost"`
+ PrevCost float64 `json:"prev_cost"`
+ WastedCost float64 `json:"wasted_cost"`
+ FailedRequests int64 `json:"failed_requests"`
+ RetriedRequests int64 `json:"retried_requests"`
+ }
+ err = g.DB().Ctx(ctx).Raw(`
+ SELECT
+ COALESCE(SUM(CASE WHEN created_at >= ? THEN total_cost END), 0) AS cur_cost,
+ COALESCE(SUM(CASE WHEN created_at >= ? AND created_at < ? THEN total_cost END), 0) AS prev_cost,
+ COALESCE(SUM(CASE WHEN created_at >= ? AND status <> 'success' THEN total_cost END), 0) AS wasted_cost,
+ COUNT(*) FILTER (WHERE created_at >= ? AND status <> 'success') AS failed_requests,
+ COUNT(*) FILTER (WHERE created_at >= ? AND retry_index > 0) AS retried_requests
+ FROM bil_usage_logs
+ WHERE tenant_id = ? AND created_at >= ?
+ `, curStartStr, prevStartStr, prevEndStr, curStartStr, curStartStr, curStartStr, tenantID, prevStartStr).Scan(&trendRow)
+ if err != nil {
+ return nil, err
+ }
+
+ costTrend := v1.TenantCostTrend{
+ CurrentCost: roundUSD(trendRow.CurCost),
+ PreviousCost: roundUSD(trendRow.PrevCost),
+ HasPrevious: trendRow.PrevCost > 0,
+ }
+ if trendRow.PrevCost > 0 {
+ costTrend.DeltaPercent = math.Round((trendRow.CurCost-trendRow.PrevCost)/trendRow.PrevCost*10000) / 100
+ }
+
+ waste := v1.TenantWasteStat{
+ WastedCost: roundUSD(trendRow.WastedCost),
+ FailedRequests: trendRow.FailedRequests,
+ RetriedRequests: trendRow.RetriedRequests,
+ }
+ if trendRow.CurCost > 0 {
+ waste.SharePercent = math.Round(trendRow.WastedCost/trendRow.CurCost*10000) / 100
+ }
+
// 实时 RPM/TPM(Redis 滑动窗口,本租户维度;Redis 不可用时为 0)
rpm, tpm := common.GetRealtimeMetrics(ctx, tenantID)
@@ -124,6 +178,8 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
Tpm: tpm,
ActiveKeys: activeKeys,
MemberCount: memberCount,
+ CostTrend: costTrend,
+ Waste: waste,
}, nil
}
@@ -313,19 +369,105 @@ func (s *sTenant) BalancePrediction(ctx context.Context, req *v1.TenantBalancePr
return res, nil
}
-// BudgetAlerts checks member and project budget usage and returns those above 80%.
+// BudgetAlerts checks member and project budget usage and returns those above the alert threshold.
func (s *sTenant) BudgetAlerts(ctx context.Context, req *v1.TenantBudgetAlertsReq) (*v1.TenantBudgetAlertsRes, error) {
role := middleware.GetUserRole(ctx)
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
- return &v1.TenantBudgetAlertsRes{
- Members: []map[string]any{},
- Projects: []map[string]any{},
- }, nil
+ tenantID := middleware.GetTenantID(ctx)
+
+ // 项目侧:复用与预算执行同源的汇总
+ projectRows, err := tenantProjectBudgets(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ projects := make([]v1.TenantProjectAlert, 0, len(projectRows))
+ for _, r := range projectRows {
+ if r.Budget <= 0 {
+ continue
+ }
+ ratio := r.Used / r.Budget
+ if ratio < budgetAlertThreshold {
+ continue
+ }
+ projects = append(projects, v1.TenantProjectAlert{
+ Id: r.Id,
+ Name: r.Name,
+ Budget: roundUSD(r.Budget),
+ Used: roundUSD(r.Used),
+ UsagePercent: math.Round(ratio*10000) / 100,
+ })
+ }
+
+ // 成员侧:额度是控制线(超限直接拒绝调用,不走钱包兜底),因此按使用比例预警。
+ // 末尾那段周期守卫不能省:周期额度是「惰性重置」——只在成员下次发起调用时
+ // 由 relay 链路上的 needsReset/resetMemberQuota 清零(internal/logic/billing/member_quota.go)。
+ // 上个周期用满、之后再没调用过的成员,其 quota_used 会一直停在上限,
+ // 不加守卫就会永远出现在告警里。判定口径与 needsReset 一致:按 UTC 比较周期。
+ var memberRows []struct {
+ Id int64 `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ QuotaLimit float64 `json:"quota_limit"`
+ QuotaUsed float64 `json:"quota_used"`
+ QuotaPeriod string `json:"quota_period"`
+ QuotaResetAt *time.Time `json:"quota_reset_at"`
+ }
+ err = g.DB().Ctx(ctx).Raw(`
+ SELECT
+ id,
+ username,
+ COALESCE(display_name, '') AS display_name,
+ COALESCE(quota_limit, 0) AS quota_limit,
+ COALESCE(quota_used, 0) AS quota_used,
+ COALESCE(quota_period, '') AS quota_period,
+ quota_reset_at
+ FROM tnt_users
+ WHERE tenant_id = ? AND status = 'active'
+ AND COALESCE(quota_type, 'none') <> 'none'
+ AND COALESCE(quota_limit, 0) > 0
+ AND COALESCE(quota_used, 0) / quota_limit >= ?
+ AND (
+ COALESCE(quota_type, 'none') <> 'periodic'
+ OR quota_period IS NULL OR quota_period = ''
+ OR quota_reset_at IS NULL
+ OR (quota_period = 'day'
+ AND (quota_reset_at AT TIME ZONE 'UTC') >= date_trunc('day', now() AT TIME ZONE 'UTC'))
+ OR (quota_period = 'week'
+ AND (quota_reset_at AT TIME ZONE 'UTC') >= date_trunc('week', now() AT TIME ZONE 'UTC'))
+ OR (quota_period = 'month'
+ AND (quota_reset_at AT TIME ZONE 'UTC') >= date_trunc('month', now() AT TIME ZONE 'UTC'))
+ )
+ ORDER BY COALESCE(quota_used, 0) / quota_limit DESC
+ `, tenantID, budgetAlertThreshold).Scan(&memberRows)
+ if err != nil {
+ return nil, err
+ }
+
+ members := make([]v1.TenantMemberAlert, 0, len(memberRows))
+ for _, r := range memberRows {
+ alert := v1.TenantMemberAlert{
+ Id: r.Id,
+ Username: r.Username,
+ DisplayName: r.DisplayName,
+ QuotaLimit: roundUSD(r.QuotaLimit),
+ QuotaUsed: roundUSD(r.QuotaUsed),
+ UsagePercent: math.Round(r.QuotaUsed/r.QuotaLimit*10000) / 100,
+ }
+ if r.QuotaPeriod != "" {
+ if next := calcNextReset(r.QuotaResetAt, r.QuotaPeriod); next != nil {
+ alert.NextResetAt = next.Format("2006-01-02 15:04:05")
+ }
+ }
+ members = append(members, alert)
+ }
+
+ return &v1.TenantBudgetAlertsRes{Members: members, Projects: projects}, nil
}
-// GetMemberUsageRanking returns top members by usage cost in a given date range.
+// GetMemberUsageRanking returns top members by usage cost in a given date range,
+// including the change versus the previous window of the same length.
func (s *sTenant) GetMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (*v1.TenantMemberUsageRankingRes, error) {
role := middleware.GetUserRole(ctx)
if role != "owner" && role != "admin" {
@@ -341,35 +483,57 @@ func (s *sTenant) GetMemberUsageRanking(ctx context.Context, req *v1.TenantMembe
limit = 10
}
- startDate := gtime.Now().AddDate(0, 0, -days).Format("Y-m-d")
+ // 当前窗口 [curStart, now),对比窗口 [prevStart, curStart),两者等长。
+ // 对比窗口可能落进已被清理的分区(usage_log_cleanup 按 usage_log_retention_days
+ // 丢弃过期分区,默认 90 天),此时上期数据不是"为 0"而是"不存在"——
+ // 若照常相减会显示成 -100% 的假暴跌,因此直接不查、并告诉前端隐藏环比列。
+ now := gtime.Now()
+ curStart := now.AddDate(0, 0, -days).StartOfDay().Time
+ retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days")
+ if retentionDays <= 0 {
+ retentionDays = 90
+ }
+ prevAvailable := days*2 <= retentionDays
+ prevStart := curStart
+ if prevAvailable {
+ prevStart = now.AddDate(0, 0, -days*2).StartOfDay().Time
+ }
type memberUsageRow struct {
UserId int64 `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
- Requests int `json:"requests"`
+ Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalCost float64 `json:"total_cost"`
+ PrevCost float64 `json:"prev_cost"`
+ QuotaLimit float64 `json:"quota_limit"`
+ QuotaUsed float64 `json:"quota_used"`
+ QuotaType string `json:"quota_type"`
}
var records []memberUsageRow
err := g.DB().Ctx(ctx).Raw(`
SELECT
- u.id as user_id,
+ u.id AS user_id,
u.username,
- u.display_name,
- COUNT(*) as requests,
- COALESCE(SUM(ul.input_tokens), 0) as input_tokens,
- COALESCE(SUM(ul.output_tokens), 0) as output_tokens,
- COALESCE(SUM(ul.total_cost), 0) as total_cost
+ COALESCE(u.display_name, '') AS display_name,
+ COUNT(*) FILTER (WHERE ul.created_at >= ?) AS requests,
+ COALESCE(SUM(CASE WHEN ul.created_at >= ? THEN ul.input_tokens END), 0) AS input_tokens,
+ COALESCE(SUM(CASE WHEN ul.created_at >= ? THEN ul.output_tokens END), 0) AS output_tokens,
+ COALESCE(SUM(CASE WHEN ul.created_at >= ? THEN ul.total_cost END), 0) AS total_cost,
+ COALESCE(SUM(CASE WHEN ul.created_at < ? THEN ul.total_cost END), 0) AS prev_cost,
+ COALESCE(u.quota_limit, 0) AS quota_limit,
+ COALESCE(u.quota_used, 0) AS quota_used,
+ COALESCE(u.quota_type, 'none') AS quota_type
FROM bil_usage_logs ul
JOIN tnt_users u ON u.id = ul.user_id
WHERE ul.tenant_id = ? AND ul.created_at >= ?
- GROUP BY u.id, u.username, u.display_name
+ GROUP BY u.id, u.username, u.display_name, u.quota_limit, u.quota_used, u.quota_type
ORDER BY total_cost DESC
LIMIT ?
- `, tenantID, startDate+" 00:00:00", limit).Scan(&records)
+ `, curStart, curStart, curStart, curStart, curStart, tenantID, prevStart, limit).Scan(&records)
if err != nil {
return nil, err
}
@@ -377,20 +541,150 @@ func (s *sTenant) GetMemberUsageRanking(ctx context.Context, req *v1.TenantMembe
records = []memberUsageRow{}
}
- result := make([]map[string]any, 0, len(records))
+ list := make([]v1.TenantMemberUsageItem, 0, len(records))
for _, r := range records {
- result = append(result, map[string]any{
- "user_id": r.UserId,
- "username": r.Username,
- "display_name": r.DisplayName,
- "requests": r.Requests,
- "input_tokens": r.InputTokens,
- "output_tokens": r.OutputTokens,
- "total_cost": roundUSD(r.TotalCost),
- })
+ item := v1.TenantMemberUsageItem{
+ UserId: r.UserId,
+ Username: r.Username,
+ DisplayName: r.DisplayName,
+ Requests: r.Requests,
+ InputTokens: r.InputTokens,
+ OutputTokens: r.OutputTokens,
+ TotalCost: roundUSD(r.TotalCost),
+ HasPrevious: prevAvailable && r.PrevCost > 0,
+ HasQuota: r.QuotaType != "none" && r.QuotaLimit > 0,
+ }
+ if item.HasPrevious {
+ item.DeltaPercent = math.Round((r.TotalCost-r.PrevCost)/r.PrevCost*10000) / 100
+ }
+ if item.HasQuota {
+ item.QuotaPercent = math.Round(r.QuotaUsed/r.QuotaLimit*10000) / 100
+ }
+ list = append(list, item)
}
- return &v1.TenantMemberUsageRankingRes{
- List: result,
- }, nil
+ return &v1.TenantMemberUsageRankingRes{List: list, PrevAvailable: prevAvailable}, nil
+}
+
+// TeamHealth returns tenant-wide reliability and performance metrics for the current month.
+func (s *sTenant) TeamHealth(ctx context.Context, req *v1.TenantTeamHealthReq) (*v1.TenantTeamHealthRes, error) {
+ role := middleware.GetUserRole(ctx)
+ if role != "owner" && role != "admin" {
+ return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
+ }
+ tenantID := middleware.GetTenantID(ctx)
+ monthStart := gtime.Now().Format("Y-m") + "-01 00:00:00"
+
+ var row struct {
+ Total int64 `json:"total"`
+ Success int64 `json:"success"`
+ AvgFirstTokenMs float64 `json:"avg_first_token_ms"`
+ P95Ms float64 `json:"p95_ms"`
+ CacheReadTokens int64 `json:"cache_read_tokens"`
+ TotalInputTokens int64 `json:"total_input_tokens"`
+ }
+ // 分位数在 SQL 端算(照 model_comparison.go 的先例),不把明细拉回 Go —
+ // 租户级数据量远大于个人级,不能照搬 personal_dashboard 的 LIMIT 5000 做法
+ err := g.DB().Ctx(ctx).Raw(`
+ SELECT
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE status = 'success') AS success,
+ COALESCE(AVG(first_token_ms), 0) AS avg_first_token_ms,
+ COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms)
+ FILTER (WHERE status = 'success' AND latency_ms IS NOT NULL), 0) AS p95_ms,
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens
+ FROM bil_usage_logs
+ WHERE tenant_id = ? AND created_at >= ?
+ `, tenantID, monthStart).Scan(&row)
+ if err != nil {
+ return nil, err
+ }
+
+ res := &v1.TenantTeamHealthRes{
+ TotalRequests: row.Total,
+ P95Ms: math.Round(row.P95Ms*100) / 100,
+ AvgFirstTokenMs: math.Round(row.AvgFirstTokenMs*100) / 100,
+ CacheReadTokens: row.CacheReadTokens,
+ }
+ if row.Total > 0 {
+ res.SuccessRate = math.Round(float64(row.Success)/float64(row.Total)*10000) / 10000
+ }
+ // input_tokens 已统一为「含缓存总输入」口径,直接作分母
+ if row.TotalInputTokens > 0 {
+ res.CacheHitRatio = math.Round(float64(row.CacheReadTokens)/float64(row.TotalInputTokens)*10000) / 10000
+ }
+ return res, nil
+}
+
+// ProjectBudget returns budget usage for all active projects of the tenant.
+func (s *sTenant) ProjectBudget(ctx context.Context, req *v1.TenantProjectBudgetReq) (*v1.TenantProjectBudgetRes, error) {
+ role := middleware.GetUserRole(ctx)
+ if role != "owner" && role != "admin" {
+ return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
+ }
+ tenantID := middleware.GetTenantID(ctx)
+
+ rows, err := tenantProjectBudgets(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+
+ list := make([]v1.TenantProjectBudgetItem, 0, len(rows))
+ for _, r := range rows {
+ item := v1.TenantProjectBudgetItem{
+ Id: r.Id,
+ Name: r.Name,
+ Budget: roundUSD(r.Budget),
+ Used: roundUSD(r.Used),
+ HasBudget: r.Budget > 0,
+ }
+ if r.Budget > 0 {
+ item.UsagePercent = math.Round(r.Used/r.Budget*10000) / 100
+ }
+ list = append(list, item)
+ }
+
+ return &v1.TenantProjectBudgetRes{List: list}, nil
+}
+
+// projectBudgetRow 是项目预算汇总的中间行。
+type projectBudgetRow struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Budget float64 `json:"budget"`
+ Used float64 `json:"used"`
+}
+
+// tenantProjectBudgets 汇总租户全部活跃项目的预算与累计已用。
+//
+// 已用金额取自 bil_transactions,与 CheckProjectBudget 的执行口径保持一致 ——
+// 若改用 bil_usage_logs,会出现「页面显示未超预算、但项目已被系统停用」的错位。
+// 注意 tnt_projects.budget 是累计预算而非月度预算,因此这里不做时间过滤。
+func tenantProjectBudgets(ctx context.Context, tenantID int64) ([]projectBudgetRow, error) {
+ var rows []projectBudgetRow
+ err := g.DB().Ctx(ctx).Raw(`
+ SELECT
+ p.id,
+ p.name,
+ COALESCE(p.budget, 0) AS budget,
+ COALESCE(t.used, 0) AS used
+ FROM tnt_projects p
+ LEFT JOIN (
+ SELECT project_id, SUM(-amount) AS used
+ FROM bil_transactions
+ WHERE tenant_id = ? AND type = 'consume' AND project_id IS NOT NULL
+ GROUP BY project_id
+ ) t ON t.project_id = p.id
+ WHERE p.tenant_id = ? AND p.status = 'active'
+ ORDER BY (CASE WHEN COALESCE(p.budget, 0) > 0
+ THEN COALESCE(t.used, 0) / p.budget ELSE 0 END) DESC
+ `, tenantID, tenantID).Scan(&rows)
+ if err != nil {
+ return nil, err
+ }
+ if rows == nil {
+ rows = []projectBudgetRow{}
+ }
+ return rows, nil
}
diff --git a/internal/logic/tenant/email.go b/internal/logic/tenant/email.go
index 33b8b64..2822054 100644
--- a/internal/logic/tenant/email.go
+++ b/internal/logic/tenant/email.go
@@ -175,8 +175,9 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
}
// Send notification emails (fire and forget)
+ // WithoutCancel:脱离请求取消但保留 request_id 等链路值
+ bgCtx := context.WithoutCancel(ctx)
go func() {
- bgCtx := context.Background()
emailCfg, err := common.EmailConfigFromOptions(bgCtx)
if err != nil {
return
diff --git a/internal/logic/tenant/personal_dashboard.go b/internal/logic/tenant/personal_dashboard.go
index dc94faf..8af047d 100644
--- a/internal/logic/tenant/personal_dashboard.go
+++ b/internal/logic/tenant/personal_dashboard.go
@@ -7,6 +7,8 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/dao"
+ "github.com/qianfree/team-api/internal/logic/billing"
+ "github.com/shopspring/decimal"
v1 "github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/middleware"
@@ -104,56 +106,125 @@ func (s *sTenant) PersonalDashboard(ctx context.Context, req *v1.PersonalDashboa
}
}
- // Query 3: latency percentiles + cache stats
- type latencyRow struct {
- AvgMs float64 `json:"avg_ms"`
- AvgFirstTokenMs float64 `json:"avg_first_token_ms"`
- CacheCreationTkns int64 `json:"cache_creation_tokens"`
- CacheReadTkns int64 `json:"cache_read_tokens"`
- TotalInputTkns int64 `json:"total_input_tokens"`
+ // Query 3: 延迟与缓存统计,按模型分组。
+ //
+ // 分组是为了推导「缓存省了多少钱」:input_cost 只给非缓存的基础输入计价
+ // (见 billing/pricing.go),因此模型内的混合输入单价 = input_cost / base_input_tokens;
+ // 用该单价给 cache_read_tokens 重新计价、再减去实际缓存费用,差额即节省额。
+ // 跨模型混算会严重失真(不同模型单价可差十几倍),所以必须逐模型算再求和。
+ //
+ // 分组后把 AVG 换成 SUM/COUNT 在 Go 里重新合并——COUNT(*) FILTER (WHERE x IS NOT NULL)
+ // 与 AVG 的分母口径一致,因此合并结果与原先的整体 AVG 完全相等。
+ type cacheRow struct {
+ LatencyCount int64 `json:"latency_count"`
+ FirstTokenCount int64 `json:"first_token_count"`
+ SumLatencyMs float64 `json:"sum_latency_ms"`
+ SumFirstTokenMs float64 `json:"sum_first_token_ms"`
+ CacheCreationTkns int64 `json:"cache_creation_tokens"`
+ CacheReadTkns int64 `json:"cache_read_tokens"`
+ TotalInputTkns int64 `json:"total_input_tokens"`
+ BaseInputTkns int64 `json:"base_input_tokens"`
+ InputCost decimal.Decimal `json:"input_cost"`
+ CacheReadCost decimal.Decimal `json:"cache_read_cost"`
}
- var lat latencyRow
+ var cacheRows []cacheRow
err = g.DB().Ctx(ctx).Raw(`
SELECT
- COALESCE(AVG(latency_ms), 0) as avg_ms,
- COALESCE(AVG(first_token_ms), 0) as avg_first_token_ms,
- COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
- COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
- COALESCE(SUM(input_tokens), 0) as total_input_tokens
+ COUNT(*) FILTER (WHERE latency_ms IS NOT NULL) AS latency_count,
+ COUNT(*) FILTER (WHERE first_token_ms IS NOT NULL) AS first_token_count,
+ COALESCE(SUM(latency_ms), 0) AS sum_latency_ms,
+ COALESCE(SUM(first_token_ms), 0) AS sum_first_token_ms,
+ COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens,
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
+ COALESCE(SUM(GREATEST(input_tokens - cache_read_tokens - cache_creation_tokens, 0)), 0) AS base_input_tokens,
+ COALESCE(SUM(input_cost), 0) AS input_cost,
+ COALESCE(SUM(cache_read_cost), 0) AS cache_read_cost
FROM bil_usage_logs
WHERE user_id = ? AND tenant_id = ? AND created_at >= ?
- `, userID, tenantID, monthStart).Scan(&lat)
+ GROUP BY model_name
+ `, userID, tenantID, monthStart).Scan(&cacheRows)
if err != nil {
return nil, err
}
- // Percentile calculation: fetch latency values and compute in Go
- latency := v1.PersonalLatency{
- AvgMs: math.Round(lat.AvgMs*100) / 100,
- AvgFirstTokenMs: math.Round(lat.AvgFirstTokenMs*100) / 100,
- }
- var latencyVals []float64
- err = g.DB().Ctx(ctx).Raw(`
- SELECT latency_ms FROM bil_usage_logs
- WHERE user_id = ? AND tenant_id = ? AND created_at >= ? AND latency_ms IS NOT NULL AND status = 'success'
- ORDER BY latency_ms ASC
- LIMIT 5000
- `, userID, tenantID, monthStart).Scan(&latencyVals)
- if err == nil && len(latencyVals) > 0 {
- latency.P50Ms = math.Round(percentile(latencyVals, 0.50)*100) / 100
- latency.P95Ms = math.Round(percentile(latencyVals, 0.95)*100) / 100
- latency.P99Ms = math.Round(percentile(latencyVals, 0.99)*100) / 100
+ var (
+ latencyCount int64
+ firstTokenCount int64
+ sumLatencyMs float64
+ sumFirstTokenMs float64
+ cacheCreationTkns int64
+ cacheReadTkns int64
+ totalInputTkns int64
+ )
+ savedCost := billing.Zero
+ for _, r := range cacheRows {
+ latencyCount += r.LatencyCount
+ firstTokenCount += r.FirstTokenCount
+ sumLatencyMs += r.SumLatencyMs
+ sumFirstTokenMs += r.SumFirstTokenMs
+ cacheCreationTkns += r.CacheCreationTkns
+ cacheReadTkns += r.CacheReadTkns
+ totalInputTkns += r.TotalInputTkns
+
+ if r.BaseInputTkns <= 0 || r.CacheReadTkns <= 0 {
+ continue
+ }
+ unitPrice := billing.DivideMoney(r.InputCost, decimal.NewFromInt(r.BaseInputTkns))
+ saved := billing.SubtractMoney(
+ billing.MultiplyMoney(unitPrice, decimal.NewFromInt(r.CacheReadTkns)),
+ r.CacheReadCost,
+ )
+ if billing.IsPositive(saved) {
+ savedCost = billing.AddMoney(savedCost, saved)
+ }
}
+ // Percentile calculation: fetch latency values and compute in Go
+ latency := v1.PersonalLatency{}
+ if latencyCount > 0 {
+ latency.AvgMs = math.Round(sumLatencyMs/float64(latencyCount)*100) / 100
+ }
+ if firstTokenCount > 0 {
+ latency.AvgFirstTokenMs = math.Round(sumFirstTokenMs/float64(firstTokenCount)*100) / 100
+ }
+ // 分位数交给 SQL 算。
+ //
+ // 原实现是 `ORDER BY latency_ms ASC LIMIT 5000` 再在 Go 里求分位,
+ // 取到的是「最快的 5000 条」——月成功调用超过 5000 次的用户,报出来的 P95
+ // 实际约等于真实分布的第 (5000/N)×95 百分位,而且错在「让人安心」的方向:
+ // 月调 5 万次的用户会看到 P95 几百毫秒,真实值可能是几秒。
+ // PERCENTILE_CONT 在库内对全量排序,无论多少行都是准确值。
+ var latRow struct {
+ P50 float64 `json:"p50"`
+ P95 float64 `json:"p95"`
+ P99 float64 `json:"p99"`
+ }
+ err = g.DB().Ctx(ctx).Raw(`
+ SELECT
+ COALESCE(PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY latency_ms), 0) AS p50,
+ COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms), 0) AS p95,
+ COALESCE(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY latency_ms), 0) AS p99
+ FROM bil_usage_logs
+ WHERE user_id = ? AND tenant_id = ? AND created_at >= ?
+ AND latency_ms IS NOT NULL AND status = 'success'
+ `, userID, tenantID, monthStart).Scan(&latRow)
+ if err != nil {
+ return nil, err
+ }
+ latency.P50Ms = math.Round(latRow.P50*100) / 100
+ latency.P95Ms = math.Round(latRow.P95*100) / 100
+ latency.P99Ms = math.Round(latRow.P99*100) / 100
+
cache := v1.PersonalCache{
- CacheCreationTokens: lat.CacheCreationTkns,
- CacheReadTokens: lat.CacheReadTkns,
- TotalInputTokens: lat.TotalInputTkns,
+ CacheCreationTokens: cacheCreationTkns,
+ CacheReadTokens: cacheReadTkns,
+ TotalInputTokens: totalInputTkns,
+ SavedCost: billing.InexactFloat64(billing.RoundMoney(savedCost)),
}
// input_tokens 已统一为「含缓存总输入」口径(Claude 渠道入库时补加缓存),分母直接取总和
- totalForRatio := float64(lat.TotalInputTkns)
- if totalForRatio > 0 {
- cache.HitRatio = math.Round(float64(lat.CacheReadTkns)/totalForRatio*10000) / 10000
+ if totalInputTkns > 0 {
+ cache.HitRatio = math.Round(float64(cacheReadTkns)/float64(totalInputTkns)*10000) / 10000
}
// Query 4: quota status
@@ -184,6 +255,30 @@ func (s *sTenant) PersonalDashboard(ctx context.Context, req *v1.PersonalDashboa
quota = q
}
+ // Query 5: 最近失败的调用(排障入口)。
+ // created_at >= monthStart 不是装饰:bil_usage_logs 没有 user_id 索引,
+ // 必须靠时间下界把扫描压在 (tenant_id, created_at) 索引的窄区间内。
+ // 这里用 status IN 枚举而非 <> 'success',以便走 (status, created_at) 索引。
+ var failures []v1.PersonalFailureItem
+ err = g.DB().Ctx(ctx).Raw(`
+ SELECT
+ status,
+ model_name,
+ LEFT(COALESCE(error_message, ''), 500) AS error_message,
+ TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS') AS created_at
+ FROM bil_usage_logs
+ WHERE user_id = ? AND tenant_id = ? AND created_at >= ?
+ AND status IN ('error', 'timeout', 'cancelled')
+ ORDER BY created_at DESC
+ LIMIT 5
+ `, userID, tenantID, monthStart).Scan(&failures)
+ if err != nil {
+ return nil, err
+ }
+ if failures == nil {
+ failures = []v1.PersonalFailureItem{}
+ }
+
return &v1.PersonalDashboardRes{
Today: v1.PersonalDayStats{
Requests: stats.TodayRequests,
@@ -197,11 +292,12 @@ func (s *sTenant) PersonalDashboard(ctx context.Context, req *v1.PersonalDashboa
OutputTokens: stats.MonthOutputTokens,
TotalCost: stats.MonthTotalCost,
},
- ErrorRate: errorRate,
- Latency: latency,
- Cache: cache,
- RequestTypes: reqTypeItems,
- Quota: quota,
+ ErrorRate: errorRate,
+ Latency: latency,
+ Cache: cache,
+ RequestTypes: reqTypeItems,
+ Quota: quota,
+ RecentFailures: failures,
}, nil
}
@@ -311,22 +407,3 @@ func (s *sTenant) PersonalApiKeyUsage(ctx context.Context, req *v1.PersonalApiKe
return &v1.PersonalApiKeyUsageRes{List: records}, nil
}
-
-// percentile computes the p-th percentile from a sorted slice of float64 values.
-func percentile(sorted []float64, p float64) float64 {
- n := len(sorted)
- if n == 0 {
- return 0
- }
- if n == 1 {
- return sorted[0]
- }
- rank := p * float64(n-1)
- lower := int(math.Floor(rank))
- upper := lower + 1
- if upper >= n {
- return sorted[n-1]
- }
- frac := rank - float64(lower)
- return sorted[lower]*(1-frac) + sorted[upper]*frac
-}
diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go
index c04c23f..56e45d1 100644
--- a/internal/logic/tenant/project.go
+++ b/internal/logic/tenant/project.go
@@ -442,25 +442,26 @@ func (s *sTenant) ProjectGet(ctx context.Context, req *v1.TenantProjectGetReq) (
Fields("COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active").
Scan(&keyStats)
- // 获取项目关联的 API Key ID 列表
- keyIDs, _ := dao.ApiKeys.Ctx(ctx).
- Where("project_id", req.Id).
- Where("tenant_id", tenantID).
- Fields("id").
- Array()
-
- // 统计本月用量
+ // 统计本月用量:按写入时快照的 project_id 归因,
+ // 而不是按「当前」密钥归属反推——后者会在密钥换项目后追溯改写历史数字
var monthUsage struct {
TotalCost float64 `json:"total_cost"`
RequestCount int `json:"request_count"`
}
dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
- WhereIn("api_key_id", keyIDs).
+ Where("project_id", req.Id).
Where("created_at >= date_trunc('month', NOW())").
Fields("COALESCE(SUM(total_cost), 0) as total_cost, COUNT(*) as request_count").
Scan(&monthUsage)
+ // 预算已用:与 CheckProjectBudget 的执行口径同源(bil_transactions 累计实扣)。
+ // month_cost 是「本月·列表价」,不能拿来除累计预算——两者不是一回事。
+ budgetUsed, err := projectTotalCost(ctx, tenantID, req.Id)
+ if err != nil {
+ return nil, err
+ }
+
return &v1.TenantProjectGetRes{
Id: p.Id,
Name: p.Name,
@@ -472,6 +473,7 @@ func (s *sTenant) ProjectGet(ctx context.Context, req *v1.TenantProjectGetReq) (
TotalKeys: keyStats.Total,
MonthCost: monthUsage.TotalCost,
MonthRequests: int64(monthUsage.RequestCount),
+ BudgetUsed: budgetUsed,
}, nil
}
@@ -661,26 +663,7 @@ func (s *sTenant) ProjectUsageStats(ctx context.Context, req *v1.TenantProjectUs
return nil, common.NewNotFoundError("项目")
}
- // 获取项目关联的 API Key ID 列表
- keyIDs, _ := dao.ApiKeys.Ctx(ctx).
- Where("project_id", req.Id).
- Where("tenant_id", tenantID).
- Fields("id").
- Array()
- if len(keyIDs) == 0 {
- return &v1.TenantProjectUsageStatsRes{
- Data: map[string]any{
- "total_cost": 0,
- "total_requests": 0,
- "total_input_tokens": 0,
- "total_output_tokens": 0,
- "daily": []map[string]any{},
- "models": []map[string]any{},
- },
- }, nil
- }
-
- // 总用量
+ // 总用量(按写入时快照的 project_id 归因,口径与项目详情、仪表盘一致)
var totalStats struct {
TotalCost float64 `json:"total_cost"`
RequestCount int `json:"request_count"`
@@ -689,7 +672,7 @@ func (s *sTenant) ProjectUsageStats(ctx context.Context, req *v1.TenantProjectUs
}
dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
- WhereIn("api_key_id", keyIDs).
+ Where("project_id", req.Id).
Fields("COALESCE(SUM(total_cost), 0) as total_cost, COUNT(*) as request_count, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens").
Scan(&totalStats)
@@ -704,7 +687,7 @@ func (s *sTenant) ProjectUsageStats(ctx context.Context, req *v1.TenantProjectUs
var dailyStats []dailyStatRow
dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
- WhereIn("api_key_id", keyIDs).
+ Where("project_id", req.Id).
Where("created_at >= NOW() - INTERVAL '30 days'").
Fields("DATE(created_at) as date, COUNT(*) as request_count, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens").
Group("DATE(created_at)").
@@ -723,7 +706,7 @@ func (s *sTenant) ProjectUsageStats(ctx context.Context, req *v1.TenantProjectUs
var modelStats []modelStatRow
dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
- WhereIn("api_key_id", keyIDs).
+ Where("project_id", req.Id).
Fields("model_name, COUNT(*) as request_count, COALESCE(SUM(total_cost), 0) as total_cost").
Group("model_name").
OrderDesc("total_cost").
@@ -755,27 +738,14 @@ func (s *sTenant) ProjectUsageLogs(ctx context.Context, req *v1.TenantProjectUsa
tenantID := middleware.GetTenantID(ctx)
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
- // 获取项目关联的 API Key ID 列表
- keyIDs, _ := dao.ApiKeys.Ctx(ctx).
- Where("project_id", req.Id).
- Where("tenant_id", tenantID).
- Fields("id").
- Array()
- if len(keyIDs) == 0 {
- return &v1.TenantProjectUsageLogsRes{
- List: []map[string]any{},
- Total: 0,
- Page: page,
- PageSize: pageSize,
- }, nil
- }
-
+ // 按写入时快照的 project_id 归因,与项目详情、用量统计、仪表盘保持同一口径。
+ // 空结果自然返回空列表,不需要先查一遍密钥 ID。
var logs []usageLogRow
var err error
var total int
err = dao.BilUsageLogs.Ctx(ctx).
Where("tenant_id", tenantID).
- WhereIn("api_key_id", keyIDs).
+ Where("project_id", req.Id).
Fields("id, model_name, relay_mode, input_tokens, output_tokens, total_cost, latency_ms, status, error_message, created_at").
OrderDesc("created_at").
Page(page, pageSize).
diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go
index f1cfe01..357494b 100644
--- a/internal/middleware/rbac.go
+++ b/internal/middleware/rbac.go
@@ -234,6 +234,8 @@ var adminPermissionRules = []permissionRule{
{method: "GET", path: "/api/admin/settings/categories", perm: "system:view"},
{method: "GET", prefix: "/api/admin/settings/", perm: "system:view"},
{method: "PUT", prefix: "/api/admin/settings/", perm: "system:edit"},
+ // 配置连通性测试(对象存储 / 邮件):会真实调用外部服务并可能发信,按写权限管控
+ {method: "POST", prefix: "/api/admin/settings/", perm: "system:edit"},
// Payment settings & channels (system scope)
{method: "GET", path: "/api/admin/payment-channels", perm: "system:view"},
{method: "PUT", prefix: "/api/admin/payment-channels/", perm: "system:edit"},
@@ -304,6 +306,11 @@ var adminPermissionRules = []permissionRule{
// ── dashboard and async task management ──
{method: "GET", prefix: "/api/admin/dashboard", perm: "dashboard:view"},
+ // 工作台读接口只要求 dashboard:view:跨域条目由 logic 层按 channel:view / billing:view /
+ // support:view 等逐条过滤,进得来不等于看得到,权限收敛在数据侧而非路由侧。
+ // 工作台没有写接口 —— 待办由源表实时派生,唯一的处置方式是去源头解决。
+ {method: "GET", path: "/api/admin/workbench/summary", perm: "dashboard:view"},
+ {method: "GET", path: "/api/admin/workbench/badges", perm: "dashboard:view"},
{method: "GET", path: "/api/admin/tasks", perm: "task:view"},
{method: "GET", prefix: "/api/admin/tasks/", perm: "task:view"},
{method: "POST", prefix: "/api/admin/tasks/", suffix: "/cancel", perm: "task:edit"},
diff --git a/internal/service/admin.go b/internal/service/admin.go
index 59beb6b..2bd912f 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -118,6 +118,8 @@ type (
// GetProviderDefaultURLs 获取供应商默认 API 地址
GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error)
// GetChannelHealthTrend 获取渠道健康趋势数据(健康度、延迟在 SQL 层四舍五入取整,避免展示小数)
+ // 不再返回 stability_score / consecutive_failures:健康体系重写后这两列已停止写入,
+ // 返回恒定值只会让前端画出两条毫无意义的常量线。
GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error)
// ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层),
// 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。
@@ -182,9 +184,10 @@ type (
// 穿透冻结会破坏预扣一致性),DB 侧仅记流水;流水失败时逆转 Redis 已发生的变动。
AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error)
// OfflineRecharge 线下充值入账(管理后台)
- // 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。
+ // 场景:用户线下银行转账(人民币 CNY),运营确认到账后入账。
+ // 本位币=USD 时按平台汇率换算入账;本位币=CNY 时直接入账(无换汇)。
// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、
- // 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号),
+ // 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号),
// 供现金对账与开票追溯。
OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error)
// GetWalletInfo 获取租户钱包信息(管理后台)
@@ -473,6 +476,15 @@ type (
// 的掩码逻辑(config.GetCategoryWithValues)以及 Turnstile 校验的 "******" 处理保持一致,
// 从而支持管理员在「未点保存」的状态下直接测试表单中新填/改动的配置。
TestStorageConfig(ctx context.Context, req *v1.AdminStorageTestReq) (*v1.AdminStorageTestRes, error)
+ // TestEmailConfig 发送一封测试邮件,验证 SMTP 配置是否可用。
+ //
+ // 与 TestStorageConfig 同构:请求体携带表单中的邮件配置(可能尚未保存),字段为空或为掩码
+ // "******" 时回落到已保存的值,从而支持管理员在「未点保存」的状态下直接测试改动。
+ // 收件人留空时发送给当前登录管理员的邮箱。
+ //
+ // 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试
+ // 三次也不会变好),不该让管理员多等十几秒的退避。
+ TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error)
// TaskList 大模型异步任务列表
TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error)
// TaskDetail 大模型异步任务详情
@@ -497,6 +509,8 @@ type (
ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v1.TenantExportRes, error)
// TenantApiKeySelect 租户 API Key 下拉选择列表(轻量,不返回密钥原值)。
// 供渠道调试目标过滤等管理端选择器使用;user_id > 0 时按创建者过滤。
+ // 注意:Fields 不能挂在 Count 之前的模型上——gdb 会生成 COUNT(col1, col2, ...),
+ // PostgreSQL 的 count 只接受单参数,会直接报错。
TenantApiKeySelect(ctx context.Context, req *v1.TenantApiKeySelectReq) (*v1.TenantApiKeySelectRes, error)
ListTenantLevelConfigs(ctx context.Context, _ *v1.TenantLevelConfigListReq) (*v1.TenantLevelConfigListRes, error)
CreateTenantLevelConfig(ctx context.Context, req *v1.TenantLevelConfigCreateReq) (*v1.TenantLevelConfigCreateRes, error)
@@ -533,6 +547,10 @@ type (
UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (*v1.UsageLogCleanupCreateRes, error)
UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (*v1.UsageLogCleanupListRes, error)
UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (*v1.UsageLogCleanupCancelRes, error)
+ // GetWorkbenchSummary 工作台汇总。
+ GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSummaryReq) (*v1.AdminWorkbenchSummaryRes, error)
+ // GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。
+ GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error)
}
)
diff --git a/internal/service/tenant.go b/internal/service/tenant.go
index 12a5ad1..3a72490 100644
--- a/internal/service/tenant.go
+++ b/internal/service/tenant.go
@@ -82,10 +82,15 @@ type (
ModelDistribution(ctx context.Context, req *v1.TenantModelDistributionReq) (*v1.TenantModelDistributionRes, error)
// BalancePrediction predicts when the balance will be exhausted.
BalancePrediction(ctx context.Context, req *v1.TenantBalancePredictionReq) (*v1.TenantBalancePredictionRes, error)
- // BudgetAlerts checks member and project budget usage and returns those above 80%.
+ // BudgetAlerts checks member and project budget usage and returns those above the alert threshold.
BudgetAlerts(ctx context.Context, req *v1.TenantBudgetAlertsReq) (*v1.TenantBudgetAlertsRes, error)
- // GetMemberUsageRanking returns top members by usage cost in a given date range.
+ // GetMemberUsageRanking returns top members by usage cost in a given date range,
+ // including the change versus the previous window of the same length.
GetMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (*v1.TenantMemberUsageRankingRes, error)
+ // TeamHealth returns tenant-wide reliability and performance metrics for the current month.
+ TeamHealth(ctx context.Context, req *v1.TenantTeamHealthReq) (*v1.TenantTeamHealthRes, error)
+ // ProjectBudget returns budget usage for all active projects of the tenant.
+ ProjectBudget(ctx context.Context, req *v1.TenantProjectBudgetReq) (*v1.TenantProjectBudgetRes, error)
// SendCode sends a verification code email.
SendCode(ctx context.Context, req *v1.TenantSendCodeReq) (*v1.TenantSendCodeRes, error)
// ResetPassword handles password reset.
diff --git a/migrations/000019_v0_2_16_version.sql b/migrations/000019_v0_2_16_version.sql
new file mode 100644
index 0000000..d3f1e71
--- /dev/null
+++ b/migrations/000019_v0_2_16_version.sql
@@ -0,0 +1,38 @@
+-- +goose Up
+-- 回填 bil_usage_logs.input_tokens 的「含缓存总输入」口径(恢复被丢失的 974633a8 配套回填)。
+--
+-- 背景:974633a8 将写侧 input_tokens 统一为「含缓存总输入」(Claude 口径入库时补加
+-- cache_read + cache_creation),缓存命中率公式随之改为 cache_read / input_tokens。
+-- 当时配套的历史数据回填 UPDATE 在 3676cd4d 中被误删、d01dbc79 合并迁移记录时彻底丢失,
+-- 导致存量 Claude 渠道行(channel_type=2)仍是「不含缓存」旧口径:
+-- 这些行 cache_read_tokens 可大于 input_tokens,个人工作台按月聚合后缓存命中率超过 100%。
+--
+-- 本迁移将 8/17 之前的存量 Claude 渠道行补加缓存 token,使全表口径一致:
+-- input_tokens = 普通输入 + cache_read + cache_creation
+-- OpenAI / Gemini 渠道行的 prompt 原生含缓存(cached 为其子集),不受影响;
+-- 经协议转换已按含缓存口径入库的行(如 openai 适配器解析 Claude 风格 usage,channel_type≠2)同样不触碰。
+--
+-- 注意:本迁移假设 channel_type=2 的历史行均为「不含缓存」口径(与 974633a8 原迁移一致)。
+-- 若曾在 974633a8 合入后、3676cd4d 之前(2026-08-17 10:01 ~ 11:25)对同一库执行过 goose up,
+-- 原回填已生效,禁止重复执行本迁移(会造成缓存 token 双重累加)。
+--
+-- 回填后需重跑用量日聚合刷新 bil_usage_daily(ON CONFLICT 幂等,可重复执行):
+-- team-api aggregate-usage --from 2026-08-01 --to <今天>
+
+-- +goose StatementBegin
+UPDATE bil_usage_logs
+SET input_tokens = input_tokens + COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)
+WHERE channel_type = 2
+ AND (COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)) > 0;
+-- +goose StatementEnd
+
+-- +goose Down
+-- 逆转回填:Claude 渠道行恢复「不含缓存」口径(OpenAI/Gemini 行本就含缓存,不受影响)。
+-- Down 后需再次执行 aggregate-usage 刷新 bil_usage_daily。
+
+-- +goose StatementBegin
+UPDATE bil_usage_logs
+SET input_tokens = input_tokens - COALESCE(cache_read_tokens, 0) - COALESCE(cache_creation_tokens, 0)
+WHERE channel_type = 2
+ AND (COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)) > 0;
+-- +goose StatementEnd
diff --git a/relay/channel/claude/response.go b/relay/channel/claude/response.go
index 649d2cd..ebf4b24 100644
--- a/relay/channel/claude/response.go
+++ b/relay/channel/claude/response.go
@@ -93,8 +93,12 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
}
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go
index cd0c447..d49b005 100644
--- a/relay/channel/coze/adaptor.go
+++ b/relay/channel/coze/adaptor.go
@@ -133,8 +133,12 @@ func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *com
// handleStreamResponse 流式模式:逐事件读取 Coze SSE,转换为 OpenAI SSE 格式输出
func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
@@ -164,7 +168,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
// 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
interruptedUsage := &common.Usage{}
helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen)
- return interruptedUsage, nil
+ return interruptedUsage, common.ErrStreamInterrupted
default:
}
@@ -207,7 +211,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
// 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
interruptedUsage := &common.Usage{}
helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen)
- return interruptedUsage, nil
+ return interruptedUsage, common.ErrStreamInterrupted
}
case "conversation.message.completed":
diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go
index 105511a..c5501f4 100644
--- a/relay/channel/dify/adaptor.go
+++ b/relay/channel/dify/adaptor.go
@@ -199,8 +199,12 @@ func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Respon
// handleStreamResponse 处理 Dify streaming 模式 SSE 响应
func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
@@ -228,7 +232,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted)
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen)
- return &usage, nil
+ return &usage, common.ErrStreamInterrupted
default:
}
@@ -267,7 +271,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted)
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen)
- return &usage, nil
+ return &usage, common.ErrStreamInterrupted
}
case "message_end":
diff --git a/relay/channel/gemini/response.go b/relay/channel/gemini/response.go
index 28ecad0..61d6ab4 100644
--- a/relay/channel/gemini/response.go
+++ b/relay/channel/gemini/response.go
@@ -240,8 +240,12 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
return nil, buildGeminiUpstreamError(body, resp.StatusCode)
}
- // relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
+ // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index ab6bfc0..7e7919e 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -240,6 +240,10 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo
// relaykit 流式转换(仅 chat;generate/embedding 不迁移,走旧路径)
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
+ // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return usage, common.ErrStreamInterrupted
+ }
return usage, nil
}
diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go
index 705ae24..645959c 100644
--- a/relay/channel/openai/converter.go
+++ b/relay/channel/openai/converter.go
@@ -1573,6 +1573,10 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info
helper.WriteSSEData(writer, "[DONE]")
g.Log().Infof(ctx, "[HandleResponsesStreamToChat] completed: usage=%+v, endReason=%s", totalUsage, info.StreamStatus.GetEndReason())
+ // 流中断(客户端断开/写失败/上游 error 主动终止):透传中断信号,上层按已传输部分结算
+ if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
+ return &totalUsage, common.ErrStreamInterrupted
+ }
return &totalUsage, nil
}
diff --git a/relay/common/stream_status.go b/relay/common/stream_status.go
index e6443d2..ada6279 100644
--- a/relay/common/stream_status.go
+++ b/relay/common/stream_status.go
@@ -13,7 +13,7 @@ const (
StreamEndReasonClientGone StreamEndReason = "client_gone"
StreamEndReasonError StreamEndReason = "error"
StreamEndReasonPanic StreamEndReason = "panic"
- StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop()
+ StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop()(写客户端失败/上游 error 主动终止,按中断处理)
StreamEndReasonPingFail StreamEndReason = "ping_fail" // ping 写入失败
StreamEndReasonScannerErr StreamEndReason = "scanner_err" // bufio.Scanner 错误
StreamEndReasonEOF StreamEndReason = "eof" // scanner EOF 未收到 [DONE]
@@ -71,17 +71,23 @@ func (s *StreamStatus) ErrorCount() int {
}
// IsPartialStreamEnd 流已开始传输但未正常结束,应按已消费 usage 部分结算。
+// handler_stop(dataHandler 调用 Stop)纳入中断判定:正常结束由 sr.Done()/scanner 的
+// done 标记,实际走到 Stop 的只有「写客户端失败」(客户端不可达)与「上游 error 事件
+// 主动终止」两种,均属已向客户端写出部分字节后的非正常结束,应按中断部分结算。
+// 此前 handler_stop 不算中断,客户端断开时写失败若先于 ctx 取消被观察到(first-writer-wins
+// 竞态),流会被误判为正常结束、跳过中断计费兜底,导致 0 token / 0 费用结算。
func (s *StreamStatus) IsPartialStreamEnd() bool {
r := s.GetEndReason()
return r == StreamEndReasonClientGone || r == StreamEndReasonScannerErr ||
- r == StreamEndReasonTimeout || r == StreamEndReasonPingFail
+ r == StreamEndReasonTimeout || r == StreamEndReasonPingFail ||
+ r == StreamEndReasonHandlerStop
}
-// IsNormalEnd 是否正常结束
+// IsNormalEnd 是否正常结束(handler_stop 属异常终止,不算正常结束)
func (s *StreamStatus) IsNormalEnd() bool {
s.mu.RLock()
defer s.mu.RUnlock()
- return s.reason == StreamEndReasonDone || s.reason == StreamEndReasonHandlerStop || s.reason == ""
+ return s.reason == StreamEndReasonDone || s.reason == ""
}
// HasErrors 是否有错误
diff --git a/relay/common/stream_status_test.go b/relay/common/stream_status_test.go
new file mode 100644
index 0000000..1dcc1be
--- /dev/null
+++ b/relay/common/stream_status_test.go
@@ -0,0 +1,40 @@
+package common
+
+import (
+ "errors"
+ "testing"
+)
+
+// TestIsPartialStreamEnd_ReasonCoverage 流式结束原因的中断判定覆盖。
+// handler_stop(写客户端失败/上游 error 主动终止)必须算部分流中断:
+// 客户端断开时写失败常先于 ctx 取消被观察到(SetEndReason 为 first-writer-wins),
+// 漏判会跳过中断计费兜底,按 0 token 成功结算。
+func TestIsPartialStreamEnd_ReasonCoverage(t *testing.T) {
+ partialReasons := []StreamEndReason{
+ StreamEndReasonClientGone,
+ StreamEndReasonScannerErr,
+ StreamEndReasonTimeout,
+ StreamEndReasonPingFail,
+ StreamEndReasonHandlerStop,
+ }
+ for _, reason := range partialReasons {
+ s := NewStreamStatus()
+ s.SetEndReason(reason, errors.New("boom"))
+ if !s.IsPartialStreamEnd() {
+ t.Errorf("IsPartialStreamEnd(%s) = false, want true", reason)
+ }
+ if s.IsNormalEnd() {
+ t.Errorf("IsNormalEnd(%s) = true, want false", reason)
+ }
+ }
+
+ // 正常结束不算中断
+ s := NewStreamStatus()
+ s.SetEndReason(StreamEndReasonDone, nil)
+ if s.IsPartialStreamEnd() {
+ t.Error("IsPartialStreamEnd(done) = true, want false")
+ }
+ if !s.IsNormalEnd() {
+ t.Error("IsNormalEnd(done) = false, want true")
+ }
+}
diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go
index 9cd9362..e48167a 100644
--- a/relay/handler/relay_handler.go
+++ b/relay/handler/relay_handler.go
@@ -374,6 +374,12 @@ func settleSuccessfulRequest(
RetryIndex: info.RetryIndex,
}
+ // 流式追踪:正常结束也落库(done / eof)。eof(上游未发 [DONE] 即结束)按正常结束
+ // 结算但属上游侧异常信号,记录后可在用量日志直接区分,无需翻应用日志。
+ if info.StreamStatus != nil {
+ usageRecord.StreamEndReason = string(info.StreamStatus.GetEndReason())
+ }
+
// 填充结算费用数据
if settleResult != nil {
usageRecord.TotalCost = settleResult.BaseCost
@@ -692,7 +698,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
}
- recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
+ recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus)
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
@@ -710,14 +716,16 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
err = helper.RemapStatusCode(err, info.ChannelMeta.Settings.StatusCodeMapping)
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
- g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: adaptor=%s, model=%s, reason=%s",
- adaptor.GetChannelName(), v.modelName, info.StreamStatus.Summary())
+ g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: request=%s tenant=%d channel=%d(%s) adaptor=%s model=%s attempt=%d reason=%s ctx_err=%v usage=%v pre_deduct=%.6f",
+ rc.RequestID, rc.TenantID, selection.ChannelID, selection.ChannelName, adaptor.GetChannelName(), v.modelName,
+ attempt, info.StreamStatus.Summary(), ctx.Err(), usage, preDeductAmount)
// 客户端已收到部分流:不可重试(响应已污染),上报健康后按流中断结算
_, _ = sess.Report(settleCtx, dispatchStatusCode(err), err, dispatch.DeliveryResponseStarted, info.LatencyMs(), 0)
streamUsage := usage
if streamUsage == nil {
streamUsage = &common.Usage{}
}
+ var interruptSettle *common.SettlementResult
if billing != nil && preDeductAmount > 0 {
settleResult, err := billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
@@ -734,8 +742,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
billing.IncrMemberQuotaUsed(settleCtx, rc.TenantID, rc.UserID, settleResult.ActualCost)
billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost)
}
+ interruptSettle = settleResult
}
- recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage)
+ recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle, info.StreamStatus)
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
return usage, v.billingResult, err
@@ -783,7 +792,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
}
- recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
+ recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus)
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
@@ -952,7 +961,7 @@ func handleChannelUnavailable(
"当前模型暂时不可用",
constant.ErrAllChannelsFailed,
)
- recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr)
+ recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr, nil)
return &channelUnavailableResult{nil, v.billingResult, allFailedErr}
}
@@ -1027,10 +1036,34 @@ func (e *RelayErrorWithRateLimit) Error() string {
return e.Message
}
+// applyStreamEndDiag 把流式结束原因落进失败用量记录,替代无归因价值的哨兵文案。
+// stream_end_reason 区分 client_gone / handler_stop / scanner_err / timeout / ping_fail / eof,
+// error_message 保留真实底层错误(ctx 取消原因、写客户端失败的 TCP 错误等);
+// 哨兵错误(ErrStreamInterrupted)或状态里已带底层错误时直接用摘要,其余追加在原始错误后。
+// error_message 对租户端可见,全部经 RedactMessage/SafeUpstreamErrorMessage 脱敏:
+// 摘要中的本地连接地址抹 IP,外层错误按传输层归一化,禁止上游域名/URL/IP 落入用量日志。
+func applyStreamEndDiag(record *common.UsageRecord, streamStatus *common.StreamStatus, err error) {
+ if streamStatus == nil {
+ return
+ }
+ reason := streamStatus.GetEndReason()
+ if reason == "" {
+ return
+ }
+ record.StreamEndReason = string(reason)
+ summary := helper.RedactMessage(streamStatus.Summary())
+ if err == nil || errors.Is(err, common.ErrStreamInterrupted) || streamStatus.Error() != nil {
+ record.ErrorMessage = summary
+ return
+ }
+ record.ErrorMessage = helper.SafeUpstreamErrorMessage(err) + " [stream: " + summary + "]"
+}
+
// recordFailedUsage 记录失败用量。
// selection 非 nil 时记录具体失败渠道(ID/名称/类型/上游模型),便于在用量日志定位失败渠道;
// nil 表示无单一渠道(全部渠道失败),渠道字段留空,失败详情见 error_message。
-func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error) {
+// streamStatus 非 nil 且已有结束原因时,同步落 stream_end_reason 并修正 error_message。
+func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, streamStatus *common.StreamStatus) {
record := &common.UsageRecord{
TenantID: rc.TenantID,
UserID: rc.UserID,
@@ -1043,8 +1076,11 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection
Success: false,
RequestID: rc.RequestID,
Status: "error",
- ErrorMessage: err.Error(),
+ // 租户端用量日志可见 error_message,须经脱敏(上游域名/URL/IP 禁止落库);
+ // 完整原始错误保留在应用日志与审计 ForwardingTrace(仅管理员可见)
+ ErrorMessage: helper.SafeUpstreamErrorMessage(err),
}
+ applyStreamEndDiag(record, streamStatus, err)
if selection != nil {
record.ChannelID = selection.ChannelID
record.ChannelName = selection.ChannelName
@@ -1056,7 +1092,7 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection
// recordFailedUsageWithTokens 记录失败用量(含 token 明细,用于流中断等已有部分 usage 的场景)。
// 与 recordFailedUsage 的区别:此函数会填充 token 字段,避免报表中流中断记录的 token 全为 0。
-func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage) {
+func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage, settleResult *common.SettlementResult, streamStatus *common.StreamStatus) {
record := &common.UsageRecord{
TenantID: rc.TenantID,
UserID: rc.UserID,
@@ -1069,8 +1105,11 @@ func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext,
Success: false,
RequestID: rc.RequestID,
Status: "error",
- ErrorMessage: err.Error(),
+ // 租户端用量日志可见 error_message,须经脱敏(上游域名/URL/IP 禁止落库);
+ // 完整原始错误保留在应用日志与审计 ForwardingTrace(仅管理员可见)
+ ErrorMessage: helper.SafeUpstreamErrorMessage(err),
}
+ applyStreamEndDiag(record, streamStatus, err)
if selection != nil {
record.ChannelID = selection.ChannelID
record.ChannelName = selection.ChannelName
@@ -1082,6 +1121,17 @@ func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext,
record.CompletionTokens = usage.CompletionTokens
record.TotalTokens = usage.TotalTokens
}
+ // 流中断结算成功时补充费用字段:中断已按已传输部分实际扣费,用量记录须与账本一致,
+ // 否则后台看到的中断请求永远是 0 费用(结算与用量记录是两条独立写入链路)
+ if settleResult != nil {
+ record.TotalCost = settleResult.BaseCost
+ record.ActualCost = settleResult.ActualCost
+ record.PreDeductAmount = settleResult.PreDeductAmount
+ record.RefundAmount = settleResult.RefundAmount
+ record.SupplementAmount = settleResult.SupplementAmount
+ record.BillingMode = settleResult.BillingMode
+ record.BillingSource = settleResult.BillingSource
+ }
provider.RecordUsage(context.Background(), record)
}
diff --git a/relay/handler/stream_diag_test.go b/relay/handler/stream_diag_test.go
new file mode 100644
index 0000000..51d20f5
--- /dev/null
+++ b/relay/handler/stream_diag_test.go
@@ -0,0 +1,131 @@
+package handler
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/qianfree/team-api/relay/common"
+ "github.com/qianfree/team-api/relay/constant"
+)
+
+// TestApplyStreamEndDiag_SentinelReplacedBySummary 哨兵错误(ErrStreamInterrupted)本身无归因价值,
+// error_message 必须换成 StreamStatus 摘要,让用量日志能区分 context canceled / TCP 写失败等真实原因。
+func TestApplyStreamEndDiag_SentinelReplacedBySummary(t *testing.T) {
+ status := common.NewStreamStatus()
+ status.SetEndReason(common.StreamEndReasonClientGone, context.Canceled)
+
+ record := &common.UsageRecord{ErrorMessage: common.ErrStreamInterrupted.Error()}
+ applyStreamEndDiag(record, status, common.ErrStreamInterrupted)
+
+ if record.StreamEndReason != string(common.StreamEndReasonClientGone) {
+ t.Errorf("StreamEndReason = %q, want %q", record.StreamEndReason, common.StreamEndReasonClientGone)
+ }
+ want := "client_gone: context canceled"
+ if record.ErrorMessage != want {
+ t.Errorf("ErrorMessage = %q, want %q", record.ErrorMessage, want)
+ }
+}
+
+// TestApplyStreamEndDiag_StatusCarriesErrorSummaryWins 状态本身带底层错误(如 handler_stop 的
+// 写客户端失败)时摘要信息量高于外层错误,直接以摘要作为 error_message。
+func TestApplyStreamEndDiag_StatusCarriesErrorSummaryWins(t *testing.T) {
+ status := common.NewStreamStatus()
+ writeErr := errors.New("write tcp 127.0.0.1:18888->127.0.0.1:6396: wsasend: connection forcibly closed")
+ status.SetEndReason(common.StreamEndReasonHandlerStop, writeErr)
+
+ record := &common.UsageRecord{ErrorMessage: "upstream error"}
+ applyStreamEndDiag(record, status, errors.New("upstream error"))
+
+ if !strings.Contains(record.ErrorMessage, "write tcp") {
+ t.Errorf("ErrorMessage should keep underlying write error, got %q", record.ErrorMessage)
+ }
+ if record.StreamEndReason != string(common.StreamEndReasonHandlerStop) {
+ t.Errorf("StreamEndReason = %q, want %q", record.StreamEndReason, common.StreamEndReasonHandlerStop)
+ }
+}
+
+// TestApplyStreamEndDiag_RealErrorKeptWithSummary 非哨兵外层错误(如上游 5xx)且状态无底层错误时
+// (如 eof),保留原始错误并把流结束原因追加在后,两条线索都不丢。
+func TestApplyStreamEndDiag_RealErrorKeptWithSummary(t *testing.T) {
+ status := common.NewStreamStatus()
+ status.SetEndReason(common.StreamEndReasonEOF, nil)
+
+ upstreamErr := errors.New("upstream returned HTTP 502")
+ record := &common.UsageRecord{ErrorMessage: upstreamErr.Error()}
+ applyStreamEndDiag(record, status, upstreamErr)
+
+ if !strings.HasPrefix(record.ErrorMessage, upstreamErr.Error()) {
+ t.Errorf("ErrorMessage should keep original error, got %q", record.ErrorMessage)
+ }
+ if !strings.Contains(record.ErrorMessage, string(common.StreamEndReasonEOF)) {
+ t.Errorf("ErrorMessage should append stream summary, got %q", record.ErrorMessage)
+ }
+ if record.StreamEndReason != string(common.StreamEndReasonEOF) {
+ t.Errorf("StreamEndReason = %q, want %q", record.StreamEndReason, common.StreamEndReasonEOF)
+ }
+}
+
+// TestApplyStreamEndDiag_NoopCases nil 状态与未设置结束原因的状态不得改动记录。
+func TestApplyStreamEndDiag_NoopCases(t *testing.T) {
+ record := &common.UsageRecord{ErrorMessage: "raw"}
+ applyStreamEndDiag(record, nil, errors.New("x"))
+ if record.StreamEndReason != "" || record.ErrorMessage != "raw" {
+ t.Errorf("nil status should be no-op, got reason=%q message=%q", record.StreamEndReason, record.ErrorMessage)
+ }
+
+ empty := common.NewStreamStatus()
+ applyStreamEndDiag(record, empty, errors.New("x"))
+ if record.StreamEndReason != "" || record.ErrorMessage != "raw" {
+ t.Errorf("empty reason should be no-op, got reason=%q message=%q", record.StreamEndReason, record.ErrorMessage)
+ }
+}
+
+// TestApplyStreamEndDiag_UpstreamURLNeverLeaks 用量日志 error_message 对租户可见,
+// 编排层包装的上游传输错误(*url.Error 含完整上游域名/IP)必须归一化,禁止原样落库。
+func TestApplyStreamEndDiag_UpstreamURLNeverLeaks(t *testing.T) {
+ // 还原线上泄露形态:NewUpstreamError(502, "请求处理失败", Post "https://api.deepseek.com/...": dial tcp ...)
+ dialErr := &net.OpError{
+ Op: "dial",
+ Net: "tcp",
+ Err: errors.New("connectex: A connection attempt failed because the connected party did not properly respond"),
+ }
+ upstreamErr := &url.Error{Op: "Post", URL: "https://api.deepseek.com/anthropic/v1/messages", Err: dialErr}
+ relayErr := constant.NewUpstreamError(502, "请求处理失败", upstreamErr)
+
+ status := common.NewStreamStatus()
+ status.SetEndReason(common.StreamEndReasonEOF, nil)
+
+ record := &common.UsageRecord{ErrorMessage: relayErr.Error()}
+ applyStreamEndDiag(record, status, relayErr)
+
+ for _, leak := range []string{"api.deepseek.com", "171.105.220.186", "https://", "/anthropic/"} {
+ if strings.Contains(record.ErrorMessage, leak) {
+ t.Errorf("error_message 泄露上游信息 %q: %q", leak, record.ErrorMessage)
+ }
+ }
+}
+
+// TestApplyStreamEndDiag_SummaryLocalAddrRedacted 摘要中的写客户端 TCP 错误含本地地址,
+// IP 必须抹除(保留 wsasend 等错误类别供排查)。
+func TestApplyStreamEndDiag_SummaryLocalAddrRedacted(t *testing.T) {
+ status := common.NewStreamStatus()
+ status.SetEndReason(common.StreamEndReasonHandlerStop,
+ errors.New("write tcp 127.0.0.1:18888->127.0.0.1:6396: wsasend: An existing connection was forcibly closed"))
+
+ record := &common.UsageRecord{ErrorMessage: "x"}
+ applyStreamEndDiag(record, status, common.ErrStreamInterrupted)
+
+ if strings.Contains(record.ErrorMessage, "127.0.0.1") {
+ t.Errorf("摘要未抹除本地 IP: %q", record.ErrorMessage)
+ }
+ if !strings.Contains(record.ErrorMessage, "wsasend") {
+ t.Errorf("应保留错误类别 wsasend: %q", record.ErrorMessage)
+ }
+ if !strings.Contains(record.ErrorMessage, string(common.StreamEndReasonHandlerStop)) {
+ t.Errorf("应保留结束原因 handler_stop: %q", record.ErrorMessage)
+ }
+}
diff --git a/relay/relaykit_bridge/stream.go b/relay/relaykit_bridge/stream.go
index c5da8ff..78d8d7e 100644
--- a/relay/relaykit_bridge/stream.go
+++ b/relay/relaykit_bridge/stream.go
@@ -69,7 +69,8 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
var (
gotFinish bool // 转换器是否已产出带 finish_reason 的结束 chunk
firstChunk bool
- transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算
+ writeFailed bool // 写客户端已失败(连接不可达),客户端断开的可靠信号
+ transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算
)
// chunkWriter:将转换器产出的 *dto.ChatCompletionStreamResponse 序列化为 SSE 写出,
@@ -106,7 +107,11 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
if err != nil {
return err
}
- return helper.WriteSSEData(safeWriter, string(data))
+ if err := helper.WriteSSEData(safeWriter, string(data)); err != nil {
+ writeFailed = true
+ return err
+ }
+ return nil
}
// writeTerminal 在转换器未产出结束 chunk 时补发一个终止 chunk,保证客户端正常收尾。
@@ -134,9 +139,17 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
monitor.TrackConverterCall(converterID, string(upstream), string(clientFormat), duration, err)
if err != nil {
- if ctx.Err() != nil {
- // 客户端断开 / 上下文取消:客户端已不可达,不写 [DONE]
- setEndReason(common.StreamEndReasonClientGone, ctx.Err())
+ // 客户端断开判定:ctx 已取消,或已出现过写客户端失败。后者兜住「写失败先于
+ // ctx 取消被观察到」的竞态与断开信号经代理链路延迟传导的场景——此前仅凭
+ // ctx.Err() 区分,这类情况会被误判为转换失败:跳过中断计费兜底、向死连接
+ // 补写 [DONE],最终按 0 token 成功结算。
+ if writeFailed || ctx.Err() != nil {
+ endErr := ctx.Err()
+ if endErr == nil {
+ endErr = err
+ }
+ // 客户端已不可达,不写 terminal/[DONE]
+ setEndReason(common.StreamEndReasonClientGone, endErr)
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
helper.ApplyInterruptedUsageFallback(info, capturedUsage, transferredTextLen)
return capturedUsage, true
diff --git a/relay/relaykit_bridge/stream_test.go b/relay/relaykit_bridge/stream_test.go
index 4367b39..c5f1ced 100644
--- a/relay/relaykit_bridge/stream_test.go
+++ b/relay/relaykit_bridge/stream_test.go
@@ -2,6 +2,8 @@ package relaykit_bridge
import (
"context"
+ "errors"
+ "net/http"
"net/http/httptest"
"strings"
"testing"
@@ -192,3 +194,74 @@ func tail(s string, n int) string {
}
return s[len(s)-n:]
}
+
+// failOnSubstringWriter 写入载荷包含 marker 时返回错误(模拟客户端已断开后的写失败),
+// 其余写入透传到内层 recorder。
+type failOnSubstringWriter struct {
+ rec *httptest.ResponseRecorder
+ marker string
+}
+
+func (w *failOnSubstringWriter) Header() http.Header { return w.rec.Header() }
+func (w *failOnSubstringWriter) WriteHeader(code int) { w.rec.WriteHeader(code) }
+func (w *failOnSubstringWriter) Write(p []byte) (int, error) {
+ if strings.Contains(string(p), w.marker) {
+ return 0, errors.New("write failed: connection reset by peer")
+ }
+ return w.rec.Body.Write(p)
+}
+
+// TestConvertStreamViaRelaykit_ClientWriteFailure 复现中断误判竞态:客户端断开由「写失败」
+// 首先暴露而 ctx 尚未取消(断开信号经代理传导延迟/first-writer-wins 场景)。
+// 此时必须按流中断处理:end reason=client_gone、usage 走中断兜底(已转发文本 2 字符/token
+// 估算),且不向死连接补写 [DONE]——而非误判为转换失败导致 0 token / 0 费用结算。
+func TestConvertStreamViaRelaykit_ClientWriteFailure(t *testing.T) {
+ claudeStream := `data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-3-opus-20240229","usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":4,"cache_creation_input_tokens":3}}}
+
+data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}}
+
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
+
+data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", how can I help you?"}}
+
+data: {"type":"content_block_stop","index":0}
+
+data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}
+
+data: {"type":"message_stop"}
+
+`
+ info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI)
+ // 与生产一致(relay_handler buildRelayInfo 设置):流中断时上游 usage 缺失,
+ // 输入 token 按请求侧估算值补齐计费
+ info.SetEstimatePromptTokens(42)
+ // ctx 用 Background(永不取消),精确模拟「写失败先于 ctx 取消被观察到」的竞态
+ w := &failOnSubstringWriter{rec: httptest.NewRecorder(), marker: "how can I help you"}
+
+ usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), w)
+ if !ok {
+ t.Fatal("expected ok=true (handled), got false")
+ }
+ if usage == nil {
+ t.Fatal("expected non-nil usage")
+ }
+ if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonClientGone {
+ t.Fatalf("end reason = %q, want %q (client write failure misclassified)", reason, common.StreamEndReasonClientGone)
+ }
+ if !info.StreamStatus.IsPartialStreamEnd() {
+ t.Fatal("IsPartialStreamEnd = false, want true")
+ }
+ // 转换器的 usage chunk 只在流末尾产出,中断时尚未捕获——prompt 按请求侧估算值(42)补齐;
+ // 两个 delta 文本("Hello"+", how can I help you?" = 5+21 = 26 字节)均在写失败前计入
+ // transferredTextLen(累计发生在写之前),中断估算 completion = (26+1)/2 = 13
+ if usage.PromptTokens != 42 {
+ t.Errorf("PromptTokens = %d, want 42 (request-side estimate fallback)", usage.PromptTokens)
+ }
+ if usage.CompletionTokens != 13 {
+ t.Errorf("CompletionTokens = %d, want 13 (interrupted estimate, got 0 => fallback skipped)", usage.CompletionTokens)
+ }
+ // 中断后不得向死连接补写 [DONE]
+ if strings.Contains(w.rec.Body.String(), "[DONE]") {
+ t.Error("output should not contain [DONE] after client disconnect")
+ }
+}
diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue
index fe25de8..bea081e 100644
--- a/web/admin/src/components/ModelPricingModal.vue
+++ b/web/admin/src/components/ModelPricingModal.vue
@@ -3,7 +3,7 @@ import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
import request from '@/utils/request'
-import { displayCurrency, formatBilling } from '@/composables/useCurrency'
+import { displayCurrency, formatBilling, cnyToUsd } from '@/composables/useCurrency'
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
@@ -80,6 +80,27 @@ function getOfficialSourceMeta(source: string) {
return officialSourceMeta[source] || { label: source, color: 'gray' }
}
+// 官方数据源价格恒为美元;本位币=CNY 时按汇率倒数折算展示与填入(汇率恒为 CNY→USD 单向配置,USD→CNY 取倒数)
+const usdToBaseRatio = computed(() => {
+ if (displayCurrency.value !== 'CNY') return 1
+ return 1 / cnyToUsd.value
+})
+
+// USD 原值 → 本位币值(4 位小数,与定价输入控件精度对齐)
+function toBasePrice(v: number | null | undefined): number {
+ const usd = Number(v ?? 0)
+ if (usd <= 0) return 0
+ return Math.round(usd * usdToBaseRatio.value * 10000) / 10000
+}
+
+// 官方参考价展示:折算为本位币直显;本位币=CNY 时括注美元原值便于核对
+function formatOfficialPrice(v: number | null | undefined): string {
+ const converted = formatBilling(toBasePrice(v), 2)
+ if (displayCurrency.value !== 'CNY') return converted
+ const usd = Number(Number(v ?? 0).toFixed(2))
+ return usd > 0 ? `${converted} ($${usd})` : converted
+}
+
function resetOfficialData() {
officialData.value = null
officialError.value = ''
@@ -111,10 +132,11 @@ async function fetchOfficialPricing() {
function applyOfficialPricing(pricing: any) {
if (!pricing) return
if (editorItems.length > 0) {
- editorItems[0].input_price = pricing.input_price
- editorItems[0].output_price = pricing.output_price
- editorItems[0].cache_read_price = pricing.cache_read_price
- editorItems[0].cache_creation_price = pricing.cache_creation_price
+ // 编辑表单按本位币原值输入,官方 USD 价格须先折算为本位币
+ editorItems[0].input_price = toBasePrice(pricing.input_price)
+ editorItems[0].output_price = toBasePrice(pricing.output_price)
+ editorItems[0].cache_read_price = toBasePrice(pricing.cache_read_price)
+ editorItems[0].cache_creation_price = toBasePrice(pricing.cache_creation_price)
}
if (pricing.billing_mode && pricing.billing_mode !== editorBillingMode.value) {
editorBillingMode.value = pricing.billing_mode
@@ -485,47 +507,52 @@ watch(() => props.visible, (val) => {
-
-
-
@@ -988,6 +1015,15 @@ watch(() => props.visible, (val) => {
min-height: 180px;
}
+.official-reference-hint {
+ margin-bottom: 10px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: var(--ta-text-secondary);
+ background: var(--color-fill-2);
+ border-radius: 4px;
+}
+
.official-source-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts
index 9bc3974..76e9dc8 100644
--- a/web/admin/src/router/admin.ts
+++ b/web/admin/src/router/admin.ts
@@ -24,6 +24,12 @@ const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/DashboardPage.vue'),
meta: { title: '仪表盘' },
},
+ {
+ path: 'workbench',
+ name: 'AdminWorkbench',
+ component: () => import('@/views/WorkbenchPage.vue'),
+ meta: { title: '工作台' },
+ },
{
path: 'profile',
name: 'AdminProfile',
diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue
index 5ab32d1..6250eeb 100644
--- a/web/admin/src/views/AdminLayout.vue
+++ b/web/admin/src/views/AdminLayout.vue
@@ -9,6 +9,7 @@ import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue'
import { useWatermark } from '@/composables/useWatermark'
import { useSiteName } from '@/composables/useSiteName'
import {
+ IconDesktop,
IconDashboard,
IconUserGroup,
IconApps,
@@ -305,6 +306,7 @@ const menuGroups = [
label: '数据看板',
icon: IconDashboard,
items: [
+ { name: 'AdminWorkbench', label: '工作台', icon: IconDesktop },
{ name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard },
{ name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand },
],
@@ -453,6 +455,33 @@ function handleClickOutside(e: MouseEvent) {
}
}
+// --- 工作台菜单红点 ---
+// 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台,
+// 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存,
+// 60s 轮询不会额外压库。
+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 0
+}
+
+async function fetchWorkbenchBadges() {
+ try {
+ const res = await request.get('/admin/workbench/badges')
+ const data = res.data?.data
+ if (!data) return
+ workbenchUrgent.value = data.urgent || 0
+ workbenchTotal.value = data.total || 0
+ } catch {
+ // 静默:红点拉取失败不该打扰用户,也不该阻塞布局
+ }
+}
+
onMounted(() => {
document.addEventListener('click', handleClickOutside)
updateMobile()
@@ -462,6 +491,9 @@ onMounted(() => {
checkUpdate()
fetchUpdateStatus() // 取后端实时版本,用于准确的"是否可更新"判断
+ fetchWorkbenchBadges()
+ badgeTimer = setInterval(fetchWorkbenchBadges, 60000)
+
axios.get('/api/settings/public').then((res) => {
const settings = res.data?.data?.settings
if (settings) {
@@ -478,6 +510,7 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updateMobile)
clearTimeout(popupHideTimer!)
+ if (badgeTimer) clearInterval(badgeTimer)
stopPolling()
unmountWatermark()
})
@@ -524,6 +557,11 @@ onUnmounted(() => {
>
+
@@ -590,6 +628,11 @@ onUnmounted(() => {
>
+
@@ -950,6 +993,28 @@ onUnmounted(() => {
text-overflow: ellipsis;
}
+/* 待办角标:默认中性灰,仅在工作台有 P0 时转红。
+ 全部标红会让红点迅速失去意义 —— 红色必须留给「现在就得看」。 */
+.admin-sidebar__badge {
+ margin-left: auto;
+ flex-shrink: 0;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ line-height: 18px;
+ text-align: center;
+ border-radius: 9px;
+ font-size: 11px;
+ font-weight: 600;
+ background: var(--color-fill-3);
+ color: var(--color-text-2);
+}
+
+.admin-sidebar__badge--urgent {
+ background: #f53f3f;
+ color: #fff;
+}
+
/* ===== Collapsed Sidebar Overrides ===== */
.admin-sidebar--collapsed .admin-sidebar__group-header {
justify-content: center;
diff --git a/web/admin/src/views/WorkbenchPage.vue b/web/admin/src/views/WorkbenchPage.vue
new file mode 100644
index 0000000..1430a2d
--- /dev/null
+++ b/web/admin/src/views/WorkbenchPage.vue
@@ -0,0 +1,848 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ m.label }}
+
{{ m.value }}
+
+
+ {{ m.growth.positive ? '▲' : '▼' }} {{ m.growth.value }}%
+
+ {{ m.sub }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ urgentCount }} 项紧急事项
+ 正在影响服务可用性或资金安全,建议立即处理
+
+ 只看这些
+
+
+
+
+
+
+ 待办事项
+ {{ filtered.length }}
+
+
+
+ {{ DOMAINS[activeDomain as DomainKey].label }}
+
+
+
+
+
+
+
+ {{ SEVERITY[item.severity as Severity].label }}
+
+
+
+ {{ DOMAINS[item.domain as DomainKey].label }}
+
+
{{ item.title }}
+
{{ timeAgo(item.occurred_at) }}
+
+
{{ item.desc }}
+
+
+ {{ item.action_text || '去处理' }}
+
+
+
+
+
+
+
+
全部处理完了
+
工作台清空是正常状态,不代表没数据。
+
+
+
+
+
+
+
+
+
+
+
+ {{ d.label }}
+ {{ d.urgent }}
+ {{ d.total }}
+
+
+
+
+
+
+ 全部
+
+
+
+ {{ m.model }}
+ {{ m.available }} / {{ m.total }}
+
+
+
+
+
+
+
+
+
+ {{ b.channel_name }}
+ {{ b.model }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/admin/src/views/system/SettingsPage.vue b/web/admin/src/views/system/SettingsPage.vue
index f57313d..7081713 100644
--- a/web/admin/src/views/system/SettingsPage.vue
+++ b/web/admin/src/views/system/SettingsPage.vue
@@ -60,6 +60,29 @@ async function testStorage() {
}
}
+// 邮件配置:发送一封测试邮件验证 SMTP 是否可用。
+// 收件人留空由后端回落到当前管理员邮箱(登录态里不含邮箱,因此不预填,成功后回显实际收件人)。
+const emailTestVisible = ref(false)
+const emailTestTo = ref('')
+async function testEmail(): Promise {
+ testing.value = true
+ try {
+ const { default: request } = await import('@/utils/request')
+ const res: any = await request.post('/admin/settings/email/test', {
+ ...formValues,
+ to: emailTestTo.value.trim(),
+ })
+ const d = res.data?.data || {}
+ Message.success(d.message || '测试邮件已发送')
+ return true
+ } catch {
+ // 失败提示由响应拦截器统一弹出(后端透出真实 SMTP 错误);返回 false 保持弹窗打开便于改收件人重试
+ return false
+ } finally {
+ testing.value = false
+ }
+}
+
async function fetchCategories() {
try {
const { default: request } = await import('@/utils/request')
@@ -108,16 +131,40 @@ onMounted(() => {
:loading="testing"
@click="testStorage"
>测试连接
+ 发送测试邮件
保存设置
+
+
+
+
+
+
+ 将使用当前表单中的邮件配置发送(无需先保存);密码留空时使用已保存的值。
+
diff --git a/web/tenant/src/components/charts/TokenTrendChart.vue b/web/tenant/src/components/charts/TokenTrendChart.vue
index ba219cc..dc98ebc 100644
--- a/web/tenant/src/components/charts/TokenTrendChart.vue
+++ b/web/tenant/src/components/charts/TokenTrendChart.vue
@@ -1,5 +1,5 @@
-
+
-
使用趋势
-
Token 消耗与请求量变化
+
{{ title }}
+
{{ subtitle }}
-
-
+
+
+
+
+
+
+
+
+
diff --git a/web/tenant/src/components/common/Icon.vue b/web/tenant/src/components/common/Icon.vue
index 1168552..2d347ca 100644
--- a/web/tenant/src/components/common/Icon.vue
+++ b/web/tenant/src/components/common/Icon.vue
@@ -106,6 +106,10 @@ const icons: Record
= {
bolt: 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z',
currencyDollar: 'M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
wallet: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9A2.25 2.25 0 0018.75 6.75H5.25A2.25 2.25 0 003 9v3m18 0V9',
+ // Heroicons outline: archive-box(套餐额度)
+ package: 'M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z',
+ // Heroicons outline 风格自绘:沙漏(余额可支撑天数)
+ hourglass: 'M6.75 3h10.5M6.75 21h10.5M7.5 3v3.257c0 2.036 4.5 3.554 4.5 5.743 0-2.189 4.5-3.707 4.5-5.743V3M7.5 21v-3.257c0-2.036 4.5-3.554 4.5-5.743 0 2.189 4.5 3.707 4.5 5.743V21',
// Business
gift: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H4.5A1.5 1.5 0 013 19.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z',
diff --git a/web/tenant/src/router/index.ts b/web/tenant/src/router/index.ts
index d0c54fe..db60f5b 100644
--- a/web/tenant/src/router/index.ts
+++ b/web/tenant/src/router/index.ts
@@ -72,8 +72,10 @@ router.beforeEach(async (to) => {
const allowedRoles = to.meta.roles
if (allowedRoles && role && !allowedRoles.includes(role)) {
- // Redirect member to their first accessible page (models)
- return { name: 'TenantModels' }
+ // 无权访问时回落到该角色自己的主页:member 回个人看板,
+ // 管理层回仪表盘。原先一律弹到「可用模型」,等于让成员永远错过个人看板。
+ const isManager = role === 'owner' || role === 'admin'
+ return { name: isManager ? 'TenantDashboard' : 'TenantPersonalDashboard' }
}
return true
diff --git a/web/tenant/src/router/tenant.ts b/web/tenant/src/router/tenant.ts
index 7a4e08a..f1e0298 100644
--- a/web/tenant/src/router/tenant.ts
+++ b/web/tenant/src/router/tenant.ts
@@ -1,4 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
+import { isTenantManager } from '@/utils/permission'
// 'vue-router' 模块类型扩展,让 meta 字段有类型提示
declare module 'vue-router' {
@@ -59,8 +60,10 @@ const tenantRoutes: RouteRecordRaw[] = [
meta: { title: '仪表盘', icon: 'grid', sort: 10, roles: ['owner', 'admin'] },
},
{
+ // 落地页按角色决定:member 没有仪表盘权限,若无条件重定向到 /tenant/dashboard
+ // 会被角色守卫再弹到「可用模型」,永远进不了本该属于他的个人看板。
path: '',
- redirect: '/tenant/dashboard',
+ redirect: () => (isTenantManager() ? '/tenant/dashboard' : '/tenant/personal-dashboard'),
},
{
path: 'personal-dashboard',
diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts
index 146e24a..2a9962f 100644
--- a/web/tenant/src/stores/tenant-auth.ts
+++ b/web/tenant/src/stores/tenant-auth.ts
@@ -61,6 +61,11 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
const isOwner = computed(() => user.value?.role === TENANT_ROLES.OWNER)
+ // 「管理层」判定:Layout 的侧栏分组、路由落地页都要用,避免各处重复写角色比较
+ const isManager = computed(
+ () => user.value?.role === TENANT_ROLES.OWNER || user.value?.role === TENANT_ROLES.ADMIN,
+ )
+
function persist(): void {
try {
const data = {
@@ -233,6 +238,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
rememberMe,
isLoggedIn,
isOwner,
+ isManager,
login,
applyTokensFrom2FA,
register,
diff --git a/web/tenant/src/utils/permission.ts b/web/tenant/src/utils/permission.ts
index aad93f3..cbddce7 100644
--- a/web/tenant/src/utils/permission.ts
+++ b/web/tenant/src/utils/permission.ts
@@ -30,6 +30,17 @@ export function hasTenantRole(role: string): boolean {
return currentRole === role
}
+/**
+ * 是否为管理层(owner / admin)。
+ * 读 localStorage 而非 Pinia:路由重定向在组件 setup 之外同步执行,
+ * 此时 store 可能尚未 hydrate,localStorage 是唯一可靠的同步来源。
+ * 组件内做条件渲染请用 store 的 isManager(响应式)。
+ */
+export function isTenantManager(): boolean {
+ const currentRole = getStoredString(TENANT_ROLE_KEY)
+ return currentRole === TENANT_ROLES.OWNER || currentRole === TENANT_ROLES.ADMIN
+}
+
export function setTenantSession(role: string, permissions: string[]): void {
localStorage.setItem(TENANT_ROLE_KEY, role)
localStorage.setItem(TENANT_PERMISSIONS_KEY, JSON.stringify(permissions))
diff --git a/web/tenant/src/utils/renderUtils.ts b/web/tenant/src/utils/renderUtils.ts
index 6a503d3..ce59234 100644
--- a/web/tenant/src/utils/renderUtils.ts
+++ b/web/tenant/src/utils/renderUtils.ts
@@ -60,6 +60,18 @@ export function formatDate(value: unknown): string {
return s
}
+/**
+ * 大数缩写:B / M / K,千位以下带本地化分隔符。
+ * 各看板页面原先各自复制了一份实现,统一收敛到这里。
+ */
+export function formatNumber(value: unknown): string {
+ const num = Number(value) || 0
+ if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`
+ return num.toLocaleString('zh-CN')
+}
+
/** Token 数量缩写:K / M */
export function formatTokens(value: unknown): string {
const num = Number(value ?? 0)
diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue
index df45934..88a0827 100644
--- a/web/tenant/src/views/Layout.vue
+++ b/web/tenant/src/views/Layout.vue
@@ -77,10 +77,7 @@ const navItems = computed(() => {
}))
})
-const canViewWallet = computed(() => {
- const role = authStore.user?.role
- return role === 'owner' || role === 'admin'
-})
+const canViewWallet = computed(() => authStore.isManager)
const activePath = computed(() => route.path)
const pageTitle = computed(() => {
const matched = route.matched
diff --git a/web/tenant/src/views/dashboard/DashboardPage.vue b/web/tenant/src/views/dashboard/DashboardPage.vue
index da412f9..6104937 100644
--- a/web/tenant/src/views/dashboard/DashboardPage.vue
+++ b/web/tenant/src/views/dashboard/DashboardPage.vue
@@ -1,6 +1,7 @@