Merge pull request #24 from qianfree/dev

用户端UI调整
This commit is contained in:
Qian authored and GitHub committed 2026-09-01 00:48:05 +08:00
commit b25d053f7b
67 files changed
+5276 -961

No files matched your search

+3 -1
View File
@@ -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
+3
View File
@@ -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)
}
+22
View File
@@ -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"` // 结果描述
}
+106
View File
@@ -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 数量,控制角标转红"`
}
+2
View File
@@ -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)
+103 -10
View File
@@ -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:"是否设置了预算"`
}
+16 -7
View File
@@ -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 {
+2 -1
View File
@@ -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"`
}
+26
View File
@@ -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` 中搜不到。
@@ -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)
}
@@ -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)
}
@@ -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)
}
+53
View File
@@ -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 {
+3 -3
View File
@@ -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",
+16 -3
View File
@@ -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)
+125
View File
@@ -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(`
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>邮件配置测试</h2>
<p>这是一封来自 <strong>%s</strong> 的测试邮件,收到即表示 SMTP 配置可用。</p>
<p style="color: #666;">发件人:%s<br/>发送时间:%s</p>
</div>
`, 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
}
+14 -9
View File
@@ -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 {
+252
View File
@@ -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
}
@@ -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)
}
}
}
+15
View File
@@ -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
+27
View File
@@ -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) {
+60 -23
View File
@@ -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" <system@mail.aifree.com>,
// 名称含中文时自动做 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
}
+67
View File
@@ -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)
}
}
}
+36 -15
View File
@@ -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.
@@ -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 端口建议开启"},
+4 -2
View File
@@ -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)
+8 -9
View File
@@ -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 {
+17 -15
View File
@@ -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,
+323 -29
View File
@@ -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
}
+2 -1
View File
@@ -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
+137 -60
View File
@@ -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
}
+18 -48
View File
@@ -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).
+7
View File
@@ -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"},
+20 -2
View File
@@ -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)
}
)
+7 -2
View File
@@ -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.
+38
View File
@@ -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
+5 -1
View File
@@ -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
}
+7 -3
View File
@@ -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":
+7 -3
View File
@@ -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":
+5 -1
View File
@@ -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
}
+4
View File
@@ -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
}
+4
View File
@@ -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
}
+10 -4
View File
@@ -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 是否有错误
+40
View File
@@ -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")
}
}
+60 -10
View File
@@ -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)
}
+131
View File
@@ -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)
}
}
+18 -5
View File
@@ -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
+73
View File
@@ -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")
}
}
+77 -41
View File
@@ -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) => {
<ASpin />
</div>
<AAlert v-else-if="officialError" type="error" :title="officialError" />
<div v-else-if="officialData" class="official-source-grid">
<div v-for="src in officialData.sources" :key="src.source" class="official-source-card">
<div class="official-source-header">
<ATag size="small" :color="getOfficialSourceMeta(src.source).color">
{{ getOfficialSourceMeta(src.source).label }}
</ATag>
<template v-else-if="officialData">
<div v-if="displayCurrency === 'CNY'" class="official-reference-hint">
数据源价格单位为美元,已按 1 USD ≈ ¥{{ (1 / cnyToUsd).toFixed(4) }} 折算为人民币,括号内为美元原值
</div>
<div class="official-source-grid">
<div v-for="src in officialData.sources" :key="src.source" class="official-source-card">
<div class="official-source-header">
<ATag size="small" :color="getOfficialSourceMeta(src.source).color">
{{ getOfficialSourceMeta(src.source).label }}
</ATag>
<template v-if="src.found && src.pricing">
<ATag v-if="src.provider" size="small" color="orangered">{{ src.provider }}</ATag>
<ATag v-if="src.mode" size="small">{{ src.mode }}</ATag>
</template>
</div>
<template v-if="src.found && src.pricing">
<ATag v-if="src.provider" size="small" color="orangered">{{ src.provider }}</ATag>
<ATag v-if="src.mode" size="small">{{ src.mode }}</ATag>
<div v-if="src.max_context_tokens || src.max_output_tokens" class="official-source-meta">
<span v-if="src.max_context_tokens">上下文 {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
<span v-if="src.max_output_tokens">输出 {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
</div>
<div class="official-prices">
<div class="official-price-row">
<span class="official-price-label">输入价格</span>
<span class="official-price-value">{{ formatOfficialPrice(src.pricing.input_price) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">输出价格</span>
<span class="official-price-value">{{ formatOfficialPrice(src.pricing.output_price) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存读取</span>
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatOfficialPrice(src.pricing.cache_read_price) + ' / 1M' : '—' }}</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存创建</span>
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatOfficialPrice(src.pricing.cache_creation_price) + ' / 1M' : '—' }}</span>
</div>
</div>
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
</template>
</div>
<template v-if="src.found && src.pricing">
<div v-if="src.max_context_tokens || src.max_output_tokens" class="official-source-meta">
<span v-if="src.max_context_tokens">上下文 {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
<span v-if="src.max_output_tokens">输出 {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
<div v-else class="official-not-found" :class="{ 'official-not-found--error': src.error }">
{{ src.error ? '数据源暂时不可用' : '未找到该模型' }}
</div>
<div class="official-prices">
<div class="official-price-row">
<span class="official-price-label">输入价格</span>
<span class="official-price-value">{{ formatBilling(src.pricing.input_price, 2) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">输出价格</span>
<span class="official-price-value">{{ formatBilling(src.pricing.output_price, 2) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存读取</span>
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatBilling(src.pricing.cache_read_price, 2) + ' / 1M' : '—' }}</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存创建</span>
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatBilling(src.pricing.cache_creation_price, 2) + ' / 1M' : '—' }}</span>
</div>
</div>
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
</template>
<div v-else class="official-not-found" :class="{ 'official-not-found--error': src.error }">
{{ src.error ? '数据源暂时不可用' : '未找到该模型' }}
</div>
</div>
</div>
</template>
</div>
</template>
</APopover>
@@ -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));
+6
View File
@@ -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',
+65
View File
@@ -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<typeof setInterval> | 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(() => {
>
<component :is="item.icon" class="admin-sidebar__icon" />
<span class="admin-sidebar__text">{{ item.label }}</span>
<span
v-if="badgeOf(item.name) > 0"
class="admin-sidebar__badge"
:class="{ 'admin-sidebar__badge--urgent': item.name === 'AdminWorkbench' && workbenchUrgent > 0 }"
>{{ badgeOf(item.name) > 99 ? '99+' : badgeOf(item.name) }}</span>
</div>
</div>
</Transition>
@@ -590,6 +628,11 @@ onUnmounted(() => {
>
<component :is="item.icon" class="admin-sidebar__icon" />
<span class="admin-sidebar__text">{{ item.label }}</span>
<span
v-if="badgeOf(item.name) > 0"
class="admin-sidebar__badge"
:class="{ 'admin-sidebar__badge--urgent': item.name === 'AdminWorkbench' && workbenchUrgent > 0 }"
>{{ badgeOf(item.name) > 99 ? '99+' : badgeOf(item.name) }}</span>
</div>
</div>
</Transition>
@@ -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;
+848
View File
@@ -0,0 +1,848 @@
<script setup lang="ts">
/**
* 工作台(运营收件箱)
*
* 与仪表盘的分工:仪表盘回答「现在怎么样」,工作台回答「我该做什么」。
* 只有满足「有人要处理 + 不处理会恶化 + 能被清空归零」三条的事才进这里。
*
* 待办由后端从各业务源表实时派生,前端不缓存、不自己判定严重度,也不持久化任何状态。
* 待办没有「标记完成」——唯一的出路是点进源头把问题解决掉,下一轮收集自然就没有它了。
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import {
IconThunderbolt,
IconBranch,
IconIdcard,
IconMessage,
IconSafe,
IconRefresh,
IconRight,
IconCheckCircleFill,
IconExclamationCircle,
IconFire,
IconUserGroup,
IconBarChart,
} from '@arco-design/web-vue/es/icon'
import request from '@/utils/request'
import { formatBilling } from '@/composables/useCurrency'
const router = useRouter()
// ===== 常量 =====
type Severity = 'p0' | 'p1' | 'p2'
type DomainKey = 'availability' | 'money' | 'customer' | 'system'
const DOMAINS: Record<DomainKey, { label: string; icon: any; color: string }> = {
availability: { label: '服务可用性', icon: IconBranch, color: '#f77234' },
money: { label: '资金安全', icon: IconIdcard, color: '#14b8a6' },
customer: { label: '客户运营', icon: IconMessage, color: '#3491fa' },
system: { label: '系统安全', icon: IconSafe, color: '#8b5cf6' },
}
const SEVERITY: Record<Severity, { label: string; color: string; bg: string }> = {
p0: { label: '紧急', color: '#f53f3f', bg: 'rgba(245, 63, 63, 0.06)' },
p1: { label: '今日', color: '#ff7d00', bg: 'transparent' },
p2: { label: '本周', color: '#86909c', bg: 'transparent' },
}
// 指标图标/配色按 key 固定,不跟随后端返回顺序
const METRIC_STYLE: Record<string, { icon: any; gradient: string }> = {
today_revenue: { icon: IconBarChart, gradient: 'linear-gradient(135deg, #0d9488, #14b8a6)' },
error_rate_5m: { icon: IconThunderbolt, gradient: 'linear-gradient(135deg, #ef4444, #f87171)' },
low_balance_tenants: { icon: IconUserGroup, gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)' },
pending_total: { icon: IconFire, gradient: 'linear-gradient(135deg, #6366f1, #818cf8)' },
}
// 路径参数路由:跳转时要走 params 而非 query
const PARAM_ROUTES: Record<string, string[]> = {
AdminChannelDetail: ['id'],
AdminTenantDetail: ['id'],
}
// ===== 状态 =====
const loading = ref(false)
const summary = ref<any>(null)
const onlyUrgent = ref(false)
const activeDomain = ref<DomainKey | 'all'>('all')
const lastRefresh = ref<Date | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
// ===== 派生 =====
const items = computed<any[]>(() => summary.value?.items || [])
const filtered = computed(() =>
items.value.filter(i => {
if (onlyUrgent.value && i.severity !== 'p0') return false
if (activeDomain.value !== 'all' && i.domain !== activeDomain.value) return false
return true
}),
)
const urgentCount = computed(() => items.value.filter(i => i.severity === 'p0').length)
const domainStats = computed(() => {
const raw: any[] = summary.value?.domains || []
return (Object.keys(DOMAINS) as DomainKey[]).map(key => {
const hit = raw.find(d => d.domain === key)
return { key, ...DOMAINS[key], total: hit?.total || 0, urgent: hit?.urgent || 0 }
})
})
// 首屏数字:后端三个 + 前端补一个「待处理事项」(它就是列表长度,没必要让后端再算一遍)
const metrics = computed(() => {
const list: any[] = (summary.value?.metrics || []).map((m: any) => ({
key: m.key,
label: m.label,
value: formatMetric(m),
sub: m.sub,
growth:
m.growth === null || m.growth === undefined
? undefined
: { value: Math.abs(Math.round(m.growth * 10) / 10), positive: m.growth >= 0 },
...(METRIC_STYLE[m.key] || METRIC_STYLE.pending_total),
}))
const p1 = items.value.filter(i => i.severity === 'p1').length
list.push({
key: 'pending_total',
label: '待处理事项',
value: String(items.value.length),
sub: `紧急 ${urgentCount.value} · 今日 ${p1}`,
...METRIC_STYLE.pending_total,
})
return list
})
function formatMetric(m: any): string {
const v = Number(m.value) || 0
if (m.unit === 'money') return formatBilling(v, 2)
if (m.unit === 'percent') return v.toFixed(1) + '%'
return v.toLocaleString()
}
const refreshText = computed(() =>
lastRefresh.value ? lastRefresh.value.toLocaleTimeString('zh-CN', { hour12: false }) : '—',
)
function timeAgo(iso: string) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin} 分钟前`
const h = Math.floor(diffMin / 60)
if (h < 24) return `${h} 小时前`
return `${Math.floor(h / 24)} 天前`
}
// ===== 数据 =====
async function fetchSummary(silent = false) {
if (!silent) loading.value = true
try {
const res = await request.get('/admin/workbench/summary')
summary.value = res.data?.data || null
lastRefresh.value = new Date()
} catch (e: any) {
if (!silent) Message.error(e.response?.data?.message || '加载工作台失败')
} finally {
if (!silent) loading.value = false
}
}
// 跳转时自动带上筛选条件,让管理员落地即看到问题现场,而不是一个空列表
function goAction(item: any) {
if (!item.action_route) return
const q = item.action_query || {}
const paramKeys = PARAM_ROUTES[item.action_route]
if (paramKeys) {
const params: Record<string, string> = {}
const query: Record<string, string> = {}
Object.entries(q).forEach(([k, v]) => {
if (paramKeys.includes(k)) params[k] = String(v)
else query[k] = String(v)
})
router.push({ name: item.action_route, params, query })
return
}
router.push({ name: item.action_route, query: q })
}
function pickDomain(key: DomainKey | 'all') {
activeDomain.value = activeDomain.value === key ? 'all' : key
}
function availClass(m: any) {
if (m.available === 0 && m.total > 0) return 'is-dead'
if (m.available < m.total) return 'is-warn'
return 'is-ok'
}
onMounted(() => {
fetchSummary()
// 后端整份结果有 30s Redis 缓存,前端 60s 轮询不会额外压库
pollTimer = setInterval(() => fetchSummary(true), 60000)
})
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<ASpin :loading="loading" style="width: 100%">
<div style="width: 100%">
<!-- 页头 -->
<div class="wb-header">
<div>
<div class="wb-header__title">工作台</div>
<div class="wb-header__desc">需要你动手处理的事。处理完就清空 —— 这里应该经常是空的。</div>
</div>
<div class="wb-header__ops">
<span class="wb-header__time">更新于 {{ refreshText }}</span>
<ASwitch v-model="onlyUrgent" size="small" />
<span class="wb-header__switch-label">仅看紧急</span>
<AButton size="small" @click="fetchSummary()">
<template #icon><IconRefresh /></template>
刷新
</AButton>
</div>
</div>
<!-- 首屏关键数字 -->
<div class="grid grid-cols-4 gap-5 mb-5 max-lg:grid-cols-2 max-md:grid-cols-2 max-md:gap-3">
<ACard
v-for="(m, idx) in metrics"
:key="m.key"
class="stat-card animate-cardAppear"
:bordered="false"
:style="{ animationDelay: `${idx * 0.06}s` }"
>
<div class="stat-card__inner">
<div>
<div class="stat-card__label">{{ m.label }}</div>
<div class="stat-card__value">{{ m.value }}</div>
<div class="stat-card__meta">
<span
v-if="m.growth"
class="stat-card__growth"
:class="m.growth.positive ? 'is-up' : 'is-down'"
>
{{ m.growth.positive ? '&#9650;' : '&#9660;' }} {{ m.growth.value }}%
</span>
<span class="stat-card__sub">{{ m.sub }}</span>
</div>
</div>
<div class="stat-card__icon" :style="{ background: m.gradient }">
<component :is="m.icon" :size="24" style="color: #fff" />
</div>
</div>
</ACard>
</div>
<!-- 紧急横幅:只有 P0 才弹,其余静默计数 -->
<div v-if="urgentCount > 0" class="wb-banner" @click="onlyUrgent = true">
<IconExclamationCircle :size="18" />
<span class="wb-banner__text">
<b>{{ urgentCount }} 项紧急事项</b>
正在影响服务可用性或资金安全,建议立即处理
</span>
<span class="wb-banner__link">只看这些 <IconRight /></span>
</div>
<div class="wb-body">
<!-- 左:统一待办流(严重度优先,不按菜单打散) -->
<ACard :bordered="false" class="wb-flow">
<template #title>
<span>待办事项</span>
<span class="wb-flow__count">{{ filtered.length }}</span>
</template>
<template #extra>
<ATag v-if="activeDomain !== 'all'" closable size="small" @close="activeDomain = 'all'">
{{ DOMAINS[activeDomain as DomainKey].label }}
</ATag>
</template>
<TransitionGroup name="wb-item" tag="div" class="wb-list">
<div
v-for="item in filtered"
:key="item.key"
class="wb-item"
:style="{
borderLeftColor: SEVERITY[item.severity as Severity].color,
background: SEVERITY[item.severity as Severity].bg,
}"
>
<div class="wb-item__head">
<ATag
size="small"
:style="{
background: SEVERITY[item.severity as Severity].color + '1a',
color: SEVERITY[item.severity as Severity].color,
border: 'none',
}"
>
{{ SEVERITY[item.severity as Severity].label }}
</ATag>
<span class="wb-item__domain" :style="{ color: DOMAINS[item.domain as DomainKey].color }">
<component :is="DOMAINS[item.domain as DomainKey].icon" :size="12" />
{{ DOMAINS[item.domain as DomainKey].label }}
</span>
<span class="wb-item__title">{{ item.title }}</span>
<span v-if="item.occurred_at" class="wb-item__time">{{ timeAgo(item.occurred_at) }}</span>
</div>
<div class="wb-item__desc">{{ item.desc }}</div>
<div class="wb-item__actions">
<AButton v-if="item.action_route" size="mini" type="primary" @click="goAction(item)">
{{ item.action_text || '去处理' }}
</AButton>
</div>
</div>
</TransitionGroup>
<div v-if="filtered.length === 0" class="wb-empty">
<IconCheckCircleFill :size="44" style="color: #10b981" />
<div class="wb-empty__title">全部处理完了</div>
<div class="wb-empty__desc">工作台清空是正常状态,不代表没数据。</div>
</div>
</ACard>
<!-- 右:分域计数 + 可用性速览 -->
<div class="wb-side">
<ACard :bordered="false" title="按域分布">
<div class="wb-domains">
<div
v-for="d in domainStats"
:key="d.key"
class="wb-domain"
:class="{ 'is-active': activeDomain === d.key }"
@click="pickDomain(d.key)"
>
<span class="wb-domain__icon" :style="{ background: d.color + '1a', color: d.color }">
<component :is="d.icon" :size="14" />
</span>
<span class="wb-domain__label">{{ d.label }}</span>
<span v-if="d.urgent > 0" class="wb-domain__urgent">{{ d.urgent }}</span>
<span class="wb-domain__total">{{ d.total }}</span>
</div>
</div>
</ACard>
<ACard :bordered="false" title="模型可用渠道">
<template #extra>
<ALink @click="router.push({ name: 'AdminChannels' })">全部</ALink>
</template>
<div v-if="(summary?.models || []).length" class="wb-avail">
<div v-for="m in (summary?.models || []).slice(0, 8)" :key="m.model" class="wb-avail__row">
<span class="wb-avail__name">{{ m.model }}</span>
<span class="wb-avail__val" :class="availClass(m)">{{ m.available }} / {{ m.total }}</span>
</div>
</div>
<AEmpty v-else description="调度目录尚未初始化" />
</ACard>
<ACard :bordered="false" title="正在熔断">
<div v-if="(summary?.breakers || []).length" class="wb-breaker">
<div v-for="(b, i) in summary.breakers" :key="i" class="wb-breaker__row">
<div class="wb-breaker__main">
<span class="wb-breaker__ch">{{ b.channel_name }}</span>
<span class="wb-breaker__model">{{ b.model }}</span>
</div>
<div class="wb-breaker__meta">
<ATag size="small" :color="b.channel_level ? 'red' : 'orange'">
{{ b.channel_level ? '渠道级' : '模型级' }}
</ATag>
<ATag v-if="b.half_open" size="small" color="arcoblue">探测中</ATag>
</div>
</div>
</div>
<AEmpty v-else description="无渠道处于熔断" />
</ACard>
</div>
</div>
</div>
</ASpin>
</template>
<style scoped>
.grid {
display: grid;
}
/* ===== 页头 ===== */
.wb-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.wb-header__title {
font-size: 22px;
font-weight: 700;
color: var(--color-text-1);
letter-spacing: -0.02em;
}
.wb-header__desc {
margin-top: 4px;
font-size: 13px;
color: var(--color-text-3);
}
.wb-header__ops {
display: flex;
align-items: center;
gap: 8px;
}
.wb-header__time {
font-size: 12px;
color: var(--color-text-3);
margin-right: 4px;
}
.wb-header__switch-label {
font-size: 13px;
color: var(--color-text-2);
margin-right: 4px;
}
/* ===== 指标卡(复用仪表盘视觉语言)===== */
.stat-card {
padding: 4px 0;
}
.stat-card__inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-card__label {
font-size: 13px;
color: var(--color-text-3);
margin-bottom: 10px;
font-weight: 500;
}
.stat-card__value {
font-size: 26px;
font-weight: 700;
color: var(--color-text-1);
letter-spacing: -0.02em;
line-height: 1;
}
.stat-card__meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.stat-card__growth.is-up {
color: #10b981;
}
.stat-card__growth.is-down {
color: #ef4444;
}
.stat-card__sub {
color: var(--color-text-3);
}
.stat-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
flex-shrink: 0;
}
/* ===== 紧急横幅 ===== */
.wb-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 8px;
background: rgba(245, 63, 63, 0.08);
border: 1px solid rgba(245, 63, 63, 0.2);
color: #f53f3f;
cursor: pointer;
transition: background 0.2s;
}
.wb-banner:hover {
background: rgba(245, 63, 63, 0.12);
}
.wb-banner__text {
flex: 1;
font-size: 13px;
color: var(--color-text-1);
}
.wb-banner__text b {
color: #f53f3f;
}
.wb-banner__link {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
/* ===== 主体两列 ===== */
.wb-body {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.wb-side {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ===== 待办流 ===== */
.wb-flow__count {
display: inline-block;
margin-left: 8px;
padding: 0 7px;
height: 18px;
line-height: 18px;
border-radius: 9px;
background: var(--color-fill-3);
color: var(--color-text-2);
font-size: 12px;
font-weight: 600;
}
/* 卡片固定高度:告警再多也在卡片内部滚动,不无限撑开页面 */
.wb-flow :deep(.arco-card-body) {
height: 640px;
overflow-y: auto;
}
/* 细滚动条,贴合整体视觉 */
.wb-flow :deep(.arco-card-body)::-webkit-scrollbar {
width: 6px;
}
.wb-flow :deep(.arco-card-body)::-webkit-scrollbar-thumb {
background: var(--color-fill-3);
border-radius: 3px;
}
.wb-list {
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.wb-item {
padding: 12px 14px;
border-radius: 6px;
border: 1px solid var(--color-border-2);
border-left-width: 3px;
transition: box-shadow 0.2s;
}
.wb-item:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.wb-item__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wb-item__domain {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
font-weight: 500;
}
.wb-item__title {
flex: 1;
min-width: 200px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.wb-item__time {
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
}
.wb-item__desc {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-3);
}
.wb-item__actions {
margin-top: 10px;
display: flex;
align-items: center;
gap: 8px;
}
/* ===== 空态:清空是正常状态 ===== */
/* 卡片 body 定高后,空态在固定高度内垂直居中 */
.wb-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.wb-empty__title {
margin-top: 12px;
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
}
.wb-empty__desc {
margin-top: 4px;
font-size: 13px;
color: var(--color-text-3);
}
/* ===== 右栏:按域分布 ===== */
.wb-domains {
display: flex;
flex-direction: column;
gap: 4px;
}
.wb-domain {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.wb-domain:hover {
background: var(--color-fill-1);
}
.wb-domain.is-active {
background: var(--color-fill-2);
}
.wb-domain__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
flex-shrink: 0;
}
.wb-domain__label {
flex: 1;
font-size: 13px;
color: var(--color-text-1);
}
.wb-domain__urgent {
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
background: #f53f3f;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 0 5px;
}
.wb-domain__total {
min-width: 22px;
text-align: right;
font-size: 14px;
font-weight: 600;
color: var(--color-text-2);
}
/* ===== 右栏:模型可用渠道 ===== */
.wb-avail {
display: flex;
flex-direction: column;
gap: 10px;
}
.wb-avail__row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
gap: 8px;
}
.wb-avail__name {
color: var(--color-text-2);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wb-avail__val {
font-weight: 600;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.wb-avail__val.is-ok {
color: #10b981;
}
.wb-avail__val.is-warn {
color: #ff7d00;
}
.wb-avail__val.is-dead {
color: #f53f3f;
}
/* ===== 右栏:熔断 ===== */
.wb-breaker {
display: flex;
flex-direction: column;
gap: 12px;
}
.wb-breaker__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wb-breaker__main {
min-width: 0;
}
.wb-breaker__ch {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
.wb-breaker__model {
display: block;
font-size: 11px;
color: var(--color-text-3);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.wb-breaker__meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ===== 动画 ===== */
@keyframes cardAppear {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-cardAppear {
animation: cardAppear 0.4s ease both;
}
.wb-item-leave-active {
transition: all 0.3s ease;
position: absolute;
width: 100%;
}
.wb-item-leave-to {
opacity: 0;
transform: translateX(24px);
}
.wb-item-move {
transition: transform 0.3s ease;
}
/* ===== 响应式 ===== */
@media (max-width: 1200px) {
.wb-body {
grid-template-columns: 1fr;
}
/* 单列布局下视口纵向空间更紧张,卡片定高同步收紧 */
.wb-flow :deep(.arco-card-body) {
height: 560px;
}
}
@media (max-width: 767px) {
.stat-card__value {
font-size: 22px;
}
.stat-card__icon {
width: 40px;
height: 40px;
border-radius: 10px;
}
.wb-header {
align-items: flex-start;
flex-direction: column;
}
.wb-item__title {
min-width: 100%;
}
.wb-flow :deep(.arco-card-body) {
height: 480px;
}
}
</style>
@@ -60,6 +60,29 @@ async function testStorage() {
}
}
// 邮件配置:发送一封测试邮件验证 SMTP 是否可用。
// 收件人留空由后端回落到当前管理员邮箱(登录态里不含邮箱,因此不预填,成功后回显实际收件人)。
const emailTestVisible = ref(false)
const emailTestTo = ref('')
async function testEmail(): Promise<boolean> {
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"
>测试连接</AButton>
<AButton
v-if="activeTab === 'email'"
@click="emailTestVisible = true"
>发送测试邮件</AButton>
<AButton type="primary" :loading="saving" @click="save">保存设置</AButton>
</div>
</ASpin>
</ACard>
</div>
</div>
<!-- 发送测试邮件 -->
<AModal
v-model:visible="emailTestVisible"
title="发送测试邮件"
:width="480"
:ok-loading="testing"
:on-before-ok="testEmail"
ok-text="发送"
>
<AFormItem label="收件人">
<AInput v-model="emailTestTo" placeholder="留空则发送给当前管理员邮箱" allow-clear />
</AFormItem>
<p class="email-test-hint">将使用当前表单中的邮件配置发送(无需先保存);密码留空时使用已保存的值。</p>
</AModal>
</div>
</template>
<style scoped>
.email-test-hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--color-text-3);
}
.settings-layout {
display: flex;
gap: 24px;
@@ -37,6 +37,9 @@ const values = useFormValues()
<AFormItem label="发件人地址" help="格式: noreply@example.com">
<AInput v-model="values['email_smtp_from']" placeholder="noreply@example.com" />
</AFormItem>
<AFormItem label="发件人名称" help="收件人看到的发件人显示名,留空则使用站点名称">
<AInput v-model="values['email_smtp_from_name']" placeholder="team-api" />
</AFormItem>
<div class="switch-row">
<span class="switch-label">启用 TLS</span>
<span class="switch-desc">587/465 端口建议开启</span>
@@ -27,7 +27,11 @@ const props = withDefaults(defineProps<{
data: () => [],
})
const colors = ['#14b8a6', '#06b6d4', '#6366f1', '#f59e0b', '#ec4899', '#3b82f6', '#10b981', '#ef4444']
// 分类色序:相邻切片必须能分辨。原顺序前两位 #14b8a6 / #06b6d4 在常视觉下色差
// ΔE 仅 7.1(阈值 15)、色盲模式 6.8,占比接近的两个模型几乎无法区分。
// 现顺序经校验:明度带 / 彩度 / 色盲分辨 / 常视觉分辨全部通过。
// 绿色 #10b981 已移出——它是语义状态色(成功),不兼任数据系列。
const colors = ['#14b8a6', '#8b5cf6', '#f59e0b', '#3b82f6', '#ec4899', '#06b6d4', '#ef4444', '#6366f1']
// 金额格式化统一走本位币;chartOptions 为 computed,tooltip 回调随 displayCurrency 变化重渲染
function formatCost(value: number): string {
@@ -72,8 +76,9 @@ const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
},
}))
// 列表在卡内滚动,不再截断——原先只显示前 5 条,其余模型完全看不到
const modelList = computed(() =>
props.data.slice(0, 5).map((item, index) => ({
props.data.map((item, index) => ({
...item,
color: colors[index % colors.length],
percent: totalCost.value > 0 ? ((item.total_cost / totalCost.value) * 100).toFixed(1) : '0',
@@ -101,24 +106,53 @@ const modelList = computed(() =>
<p class="mt-3 text-sm font-medium text-slate-500">暂无模型数据</p>
<p class="mt-1 text-xs">模型产生费用后即可查看占比</p>
</div>
<div v-else class="flex min-h-[300px] flex-col items-center gap-5 sm:flex-row 2xl:flex-col">
<div class="relative h-52 w-52 flex-shrink-0">
<!-- 左环右列:卡片高度由环图决定,列表超出部分在卡内滚动,不再撑长整张卡 -->
<div v-else class="flex flex-col items-center gap-4 sm:flex-row sm:items-start sm:gap-5">
<div class="relative h-44 w-44 flex-shrink-0 sm:h-40 sm:w-40 xl:h-44 xl:w-44">
<Doughnut :data="chartData" :options="chartOptions" />
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span class="text-xs text-slate-400">总费用</span>
<strong class="mt-1 max-w-28 truncate text-xl font-bold tracking-tight text-slate-800">{{ formatCost(totalCost) }}</strong>
<span class="text-[11px] text-slate-400">总费用</span>
<strong class="mt-0.5 max-w-24 truncate text-lg font-bold tracking-tight text-slate-800">{{ formatCost(totalCost) }}</strong>
</div>
</div>
<div class="w-full min-w-0 flex-1 space-y-3">
<div v-for="item in modelList" :key="item.model_name" class="flex items-center gap-3">
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: item.color, boxShadow: `0 0 0 4px ${item.color}18` }"></span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-slate-600" :title="item.model_name">{{ item.model_name }}</p>
<p class="mt-0.5 text-[10px] text-slate-400">{{ formatCost(item.total_cost) }}</p>
<div class="model-scroll w-full min-w-0 flex-1">
<div class="space-y-2.5 pr-1">
<div v-for="item in modelList" :key="item.model_name" class="flex items-center gap-2.5">
<span
class="h-2.5 w-2.5 flex-shrink-0 rounded-full"
:style="{ backgroundColor: item.color, boxShadow: `0 0 0 4px ${item.color}18` }"
></span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-slate-600" :title="item.model_name">{{ item.model_name }}</p>
<p class="mt-0.5 text-[10px] tabular-nums text-slate-400">{{ formatCost(item.total_cost) }}</p>
</div>
<span class="flex-shrink-0 text-xs font-semibold tabular-nums text-slate-700">{{ item.percent }}%</span>
</div>
<span class="text-xs font-semibold tabular-nums text-slate-700">{{ item.percent }}%</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 列表最大高度与左侧环图对齐:卡片高度由环图决定,多出的模型在卡内滚动。
滚动条沿用 main.css 的全局策略——默认透明,悬停/聚焦时才显形。 */
.model-scroll {
max-height: 11rem;
overflow-y: auto;
overscroll-behavior: contain;
}
@media (min-width: 640px) {
.model-scroll {
max-height: 10rem;
}
}
@media (min-width: 1280px) {
.model-scroll {
max-height: 11rem;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { Line } from 'vue-chartjs'
import {
CategoryScale,
@@ -15,6 +15,8 @@ import {
type ChartOptions,
} from 'chart.js'
import Icon from '@/components/common/Icon.vue'
import { formatBilling } from '@/composables/useCurrency'
import { formatNumber } from '@/utils/renderUtils'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
@@ -28,148 +30,170 @@ const props = withDefaults(defineProps<{
}>
loading?: boolean
days?: number
title?: string
subtitle?: string
}>(), {
data: () => [],
days: 7,
title: '消费趋势',
subtitle: '按天聚合,一次查看一个度量',
})
const emit = defineEmits<{
changeDays: [days: number]
}>()
function formatCompact(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return String(value)
type MetricKey = 'cost' | 'tokens' | 'requests'
// 单轴 + 度量切换:原实现把「Token 用量」挂左轴、「请求数」挂右轴,
// 两条不同量纲的线共用一张图,交叉点与相对高低都不代表任何含义。
// 改为一次只画一个度量,纵轴含义始终唯一。
const METRICS: Record<MetricKey, {
label: string
color: string
soft: string
value: (item: { input_tokens: number; output_tokens: number; total_cost: number; requests: number }) => number
format: (value: number) => string
}> = {
cost: {
label: '消费',
color: '#14b8a6',
soft: 'rgba(20, 184, 166, 0.12)',
value: (item) => Number(item.total_cost) || 0,
// 金额一律走本位币格式化,精度规则与 ModelDistChart 保持一致
format: (value) => (value === 0 || value >= 1 ? formatBilling(value, 2) : formatBilling(value, 4)),
},
tokens: {
label: 'Token',
color: '#8b5cf6',
soft: 'rgba(139, 92, 246, 0.12)',
value: (item) => (Number(item.input_tokens) || 0) + (Number(item.output_tokens) || 0),
format: (value) => formatNumber(value),
},
requests: {
label: '请求数',
color: '#3b82f6',
soft: 'rgba(59, 130, 246, 0.12)',
value: (item) => Number(item.requests) || 0,
format: (value) => formatNumber(value),
},
}
const METRIC_ORDER: MetricKey[] = ['cost', 'tokens', 'requests']
const metric = ref<MetricKey>('cost')
const activeMetric = computed(() => METRICS[metric.value])
function formatDateLabel(date: string): string {
const parts = date.split('-')
return parts.length === 3 ? `${parts[1]}-${parts[2]}` : date
}
const chartData = computed<ChartData<'line'>>(() => ({
labels: props.data.map((item) => formatDateLabel(item.date)),
datasets: [
{
label: 'Token 用量',
data: props.data.map((item) => item.input_tokens + item.output_tokens),
borderColor: '#14b8a6',
backgroundColor: 'rgba(20, 184, 166, 0.1)',
fill: true,
tension: 0.42,
pointRadius: 2.5,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#14b8a6',
pointBorderWidth: 2,
borderWidth: 2.25,
yAxisID: 'y',
},
{
label: '请求数',
data: props.data.map((item) => item.requests),
borderColor: '#399cf4',
backgroundColor: 'rgba(57, 156, 244, 0.04)',
fill: false,
tension: 0.42,
pointRadius: 2.5,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#399cf4',
pointBorderWidth: 2,
borderWidth: 2.25,
yAxisID: 'y1',
},
],
}))
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
animation: { duration: 650, easing: 'easeOutQuart' },
plugins: {
legend: {
position: 'top',
align: 'start',
labels: {
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 7,
boxHeight: 7,
padding: 18,
color: '#718096',
font: { size: 11, weight: 500 },
const chartData = computed<ChartData<'line'>>(() => {
const cfg = activeMetric.value
return {
labels: props.data.map((item) => formatDateLabel(item.date)),
datasets: [
{
label: cfg.label,
data: props.data.map((item) => cfg.value(item)),
borderColor: cfg.color,
backgroundColor: cfg.soft,
fill: true,
tension: 0.42,
pointRadius: 2.5,
pointHoverRadius: 5,
pointBackgroundColor: '#ffffff',
pointBorderColor: cfg.color,
pointBorderWidth: 2,
borderWidth: 2.25,
},
},
tooltip: {
backgroundColor: 'rgba(30, 36, 61, 0.92)',
titleColor: '#ffffff',
bodyColor: '#e2e8f0',
padding: 12,
cornerRadius: 12,
displayColors: true,
callbacks: {
label: (context) => {
const value = context.parsed.y ?? 0
return `${context.dataset.label}: ${formatCompact(value)}`
],
}
})
// 必须保持 computed:tooltip 与刻度回调要在 displayCurrency 变化时重新求值
const chartOptions = computed<ChartOptions<'line'>>(() => {
const cfg = activeMetric.value
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
animation: { duration: 650, easing: 'easeOutQuart' },
plugins: {
// 只有一条线时不需要图例——上方的度量切换器本身就是图例
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(30, 36, 61, 0.92)',
titleColor: '#ffffff',
bodyColor: '#e2e8f0',
padding: 12,
cornerRadius: 12,
displayColors: true,
callbacks: {
label: (context) => `${cfg.label}: ${cfg.format(context.parsed.y ?? 0)}`,
},
},
},
},
scales: {
x: {
border: { display: false },
grid: { display: false },
ticks: { color: '#9aa7bd', font: { size: 10 }, maxRotation: 0 },
},
y: {
position: 'left',
border: { display: false },
grid: { color: 'rgba(148, 163, 184, 0.14)', lineWidth: 1 },
ticks: {
color: '#9aa7bd',
font: { size: 10 },
padding: 8,
callback: (value) => formatCompact(Number(value) || 0),
scales: {
x: {
border: { display: false },
grid: { display: false },
ticks: { color: '#9aa7bd', font: { size: 10 }, maxRotation: 0 },
},
beginAtZero: true,
},
y1: {
position: 'right',
border: { display: false },
grid: { display: false },
ticks: {
color: '#9aa7bd',
font: { size: 10 },
padding: 8,
callback: (value) => formatCompact(Number(value) || 0),
y: {
position: 'left',
border: { display: false },
grid: { color: 'rgba(148, 163, 184, 0.14)', lineWidth: 1 },
ticks: {
color: '#9aa7bd',
font: { size: 10 },
padding: 8,
callback: (value) => cfg.format(Number(value) || 0),
},
beginAtZero: true,
},
beginAtZero: true,
},
},
}))
}
})
</script>
<template>
<div class="card card-prominent p-5 sm:p-6">
<div class="mb-2 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="mb-3 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">使用趋势</h2>
<p class="mt-0.5 text-xs text-slate-400">Token 消耗与请求量变化</p>
<h2 class="text-base font-semibold text-slate-900">{{ title }}</h2>
<p class="mt-0.5 text-xs text-slate-400">{{ subtitle }}</p>
</div>
<div class="inline-flex w-fit items-center gap-1 rounded-xl border border-white/90 bg-white/60 p-1 shadow-sm">
<button
v-for="option in [7, 30, 90]"
:key="option"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-all"
:class="days === option ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'"
@click="emit('changeDays', option)"
>
{{ option }} 天
</button>
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex w-fit items-center gap-1 rounded-xl bg-slate-100 p-1" role="group" aria-label="切换度量">
<button
v-for="key in METRIC_ORDER"
:key="key"
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-all"
:class="metric === key ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-400 hover:text-slate-600'"
:aria-pressed="metric === key"
@click="metric = key"
>
{{ METRICS[key].label }}
</button>
</div>
<div class="inline-flex w-fit items-center gap-1 rounded-xl border border-white/90 bg-white/60 p-1 shadow-sm" role="group" aria-label="切换时间范围">
<button
v-for="option in [7, 30, 90]"
:key="option"
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-all"
:class="days === option ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'"
:aria-pressed="days === option"
@click="emit('changeDays', option)"
>
{{ option }} 天
</button>
</div>
</div>
</div>
@@ -106,6 +106,10 @@ const icons: Record<string, string> = {
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',
+4 -2
View File
@@ -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
+4 -1
View File
@@ -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',
+6
View File
@@ -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,
+11
View File
@@ -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))
+12
View File
@@ -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)
+1 -4
View File
@@ -77,10 +77,7 @@ const navItems = computed<NavItem[]>(() => {
}))
})
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
+575 -202
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { formatBilling } from '@/composables/useCurrency'
import { formatNumber } from '@/utils/renderUtils'
import Icon from '@/components/common/Icon.vue'
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
@@ -21,6 +22,20 @@ interface WalletInfo {
warning_threshold: number
}
interface CostTrend {
current_cost: number
previous_cost: number
delta_percent: number
has_previous: boolean
}
interface WasteStat {
wasted_cost: number
share_percent: number
failed_requests: number
retried_requests: number
}
interface DashboardData {
today: DayStats | null
month: DayStats | null
@@ -29,6 +44,8 @@ interface DashboardData {
tpm: number
active_keys: number
member_count: number
cost_trend: CostTrend
waste: WasteStat
}
interface TrendPoint {
@@ -55,6 +72,10 @@ interface MemberUsageItem {
input_tokens: number
output_tokens: number
total_cost: number
delta_percent: number
has_previous: boolean
quota_percent: number
has_quota: boolean
}
interface PredictionData {
@@ -66,22 +87,40 @@ interface PredictionData {
message?: string
}
interface BudgetAlert {
members: Array<{
id: number
username: string
display_name: string
quota_limit: number
used_cost: number
usage_percent: number
}>
projects: Array<{
id: number
name: string
budget_limit: number
used_cost: number
usage_percent: number
}>
interface MemberAlert {
id: number
username: string
display_name: string
quota_limit: number
quota_used: number
usage_percent: number
next_reset_at?: string
}
interface ProjectAlert {
id: number
name: string
budget: number
used: number
usage_percent: number
}
interface TeamHealth {
total_requests: number
success_rate: number
p95_ms: number
avg_first_token_ms: number
cache_hit_ratio: number
cache_read_tokens: number
}
interface ProjectBudgetItem {
id: number
name: string
budget: number
used: number
usage_percent: number
has_budget: boolean
}
const authStore = useTenantAuthStore()
@@ -89,28 +128,24 @@ const loading = ref(false)
const chartsLoading = ref(false)
const memberUsageLoading = ref(false)
const alertsLoading = ref(false)
const healthLoading = ref(false)
const budgetLoading = ref(false)
// 消费趋势默认看最近 7 天
const selectedDays = ref(7)
const dashboardData = ref<DashboardData | null>(null)
const trendData = ref<TrendPoint[]>([])
const modelData = ref<ModelItem[]>([])
const memberUsageData = ref<MemberUsageItem[]>([])
const memberPrevAvailable = ref(true)
const predictionData = ref<PredictionData | null>(null)
const alertsData = ref<BudgetAlert | null>(null)
const safeDay = (day: DayStats | null | undefined): DayStats =>
day || { requests: 0, input_tokens: 0, output_tokens: 0, total_cost: 0 }
const memberAlerts = ref<MemberAlert[]>([])
const projectAlerts = ref<ProjectAlert[]>([])
const teamHealth = ref<TeamHealth | null>(null)
const projectBudgets = ref<ProjectBudgetItem[]>([])
const safeWallet = (wallet: WalletInfo | null | undefined): WalletInfo =>
wallet || { balance: 0, frozen_balance: 0, available: 0, warning_threshold: 0 }
function formatNumber(value: unknown): string {
const number = Number(value) || 0
if (number >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(1)}B`
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return number.toLocaleString('zh-CN')
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
function formatCost(value: unknown): string {
const number = Number(value) || 0
@@ -119,6 +154,12 @@ function formatCost(value: unknown): string {
return formatBilling(number, 6)
}
function formatMs(value: unknown): string {
const number = Number(value) || 0
if (number >= 1000) return `${(number / 1000).toFixed(2)}s`
return `${Math.round(number)}ms`
}
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '夜深了'
@@ -136,89 +177,108 @@ const formattedDate = computed(() =>
}).format(new Date()),
)
function toneOf(percent: number): 'ok' | 'warn' | 'crit' {
if (percent >= 90) return 'crit'
if (percent >= 70) return 'warn'
return 'ok'
}
// 首屏四卡:管理者先看钱——余额、套餐额度、消费与环比、还能撑几天。
// 请求数与 Token 降级为脚注,不再占据主位。
const coreStats = computed(() => {
if (!dashboardData.value) return []
const today = safeDay(dashboardData.value.today)
const month = safeDay(dashboardData.value.month)
const monthTokens = month.input_tokens + month.output_tokens
return [
{
label: '今日请求',
value: formatNumber(today.requests),
sub: `输入 ${formatNumber(today.input_tokens)} · 输出 ${formatNumber(today.output_tokens)}`,
trend: '实时统计',
icon: 'play',
color: '#7667f6',
soft: 'rgba(118, 103, 246, 0.14)',
},
{
label: '本月请求',
value: formatNumber(month.requests),
sub: `日均 ${formatNumber(month.requests / Math.max(new Date().getDate(), 1))} 次`,
trend: '本月累计',
icon: 'chart',
color: '#3b9df8',
soft: 'rgba(59, 157, 248, 0.14)',
},
{
label: '本月 Token',
value: formatNumber(monthTokens),
sub: `输入 ${formatNumber(month.input_tokens)} · 输出 ${formatNumber(month.output_tokens)}`,
trend: '调用消耗',
icon: 'bolt',
color: '#22c7b7',
soft: 'rgba(34, 199, 183, 0.14)',
},
{
label: '本月消费',
value: formatCost(month.total_cost),
sub: `今日 ${formatCost(today.total_cost)}`,
trend: '费用明细',
icon: 'creditCard',
color: '#9a58ee',
soft: 'rgba(154, 88, 238, 0.14)',
},
]
})
const accountStats = computed(() => {
if (!dashboardData.value) return []
const wallet = safeWallet(dashboardData.value.wallet)
const data = dashboardData.value
const wallet = safeWallet(data.wallet)
const trend = data.cost_trend
const prediction = predictionData.value
let predictionValue = '安全'
let predictionTone = 'text-emerald-600'
let predictionDescription = prediction?.message || '余额状态稳定'
const cards: Array<Record<string, any>> = []
cards.push({
key: 'balance',
label: '钱包可用余额',
value: formatCost(wallet.available),
sub: `另有 ${formatCost(wallet.frozen_balance)} 处于预扣冻结`,
icon: 'wallet',
color: '#0d9488',
soft: 'rgba(13, 148, 136, 0.13)',
})
cards.push({
key: 'today',
label: '今日消费',
value: formatCost(data.today?.total_cost ?? 0),
sub: `${formatNumber(data.today?.requests ?? 0)} 次调用`,
icon: 'play',
color: '#8b5cf6',
soft: 'rgba(139, 92, 246, 0.13)',
})
cards.push({
key: 'cost',
label: '本月消费',
value: formatCost(trend?.current_cost ?? data.month?.total_cost ?? 0),
sub: trend?.has_previous
? `${formatNumber(data.month?.requests ?? 0)} 次调用 · 上月同期 ${formatCost(trend.previous_cost)}`
: `${formatNumber(data.month?.requests ?? 0)} 次调用 · 上月同期无数据`,
icon: 'currencyDollar',
color: '#f59e0b',
soft: 'rgba(245, 158, 11, 0.13)',
delta: trend?.has_previous
? {
text: `${trend.delta_percent > 0 ? '↑' : trend.delta_percent < 0 ? '↓' : '→'} ${Math.abs(trend.delta_percent).toFixed(1)}%`,
tone: trend.delta_percent > 0 ? 'up' : trend.delta_percent < 0 ? 'down' : 'flat',
}
: null,
})
let forecastValue = '充足'
let forecastSub = prediction?.message || '按当前速度余额稳定'
let forecastColor = '#10b981'
let forecastSoft = 'rgba(16, 185, 129, 0.13)'
let forecastState = ''
if (prediction?.will_exhaust && prediction.days_until_exhaust !== undefined) {
predictionValue = `${prediction.days_until_exhaust} 天`
predictionTone = prediction.days_until_exhaust <= 7 ? 'text-red-500' : 'text-amber-500'
predictionDescription = `预计 ${prediction.exhaust_date || '近期'} 耗尽`
forecastValue = `${prediction.days_until_exhaust} 天`
forecastSub = `按日均 ${formatCost(prediction.daily_avg_cost)} 估算 · 预计 ${prediction.exhaust_date || '近期'} 耗尽`
forecastState = prediction.days_until_exhaust <= 7 ? 'crit' : 'warn'
forecastColor = prediction.days_until_exhaust <= 7 ? '#ef4444' : '#f59e0b'
forecastSoft = prediction.days_until_exhaust <= 7 ? 'rgba(239, 68, 68, 0.13)' : 'rgba(245, 158, 11, 0.13)'
}
cards.push({
key: 'forecast',
label: '余额可支撑',
value: forecastValue,
sub: forecastSub,
icon: 'hourglass',
color: forecastColor,
soft: forecastSoft,
state: forecastState,
})
return [
{ label: '可用余额', value: formatCost(wallet.available), description: `冻结 ${formatCost(wallet.frozen_balance)}`, icon: 'wallet', tone: 'text-violet-600', background: 'bg-violet-50' },
{ label: '活跃密钥', value: String(dashboardData.value.active_keys || 0), description: '个 API Key', icon: 'key', tone: 'text-blue-600', background: 'bg-blue-50' },
{ label: '团队成员', value: String(dashboardData.value.member_count || 0), description: '位活跃成员', icon: 'users', tone: 'text-cyan-600', background: 'bg-cyan-50' },
{ label: '余额预测', value: predictionValue, description: predictionDescription, icon: 'trendingUp', tone: predictionTone, background: 'bg-emerald-50' },
]
return cards
})
const alertItems = computed(() => {
const members = (alertsData.value?.members || []).map((item) => ({
id: `member-${item.id}`,
name: item.display_name || item.username,
percent: item.usage_percent,
type: '成员额度',
}))
const projects = (alertsData.value?.projects || []).map((item) => ({
const projects = projectAlerts.value.map((item) => ({
id: `project-${item.id}`,
name: item.name,
percent: item.usage_percent,
type: '项目预算',
detail: `${formatCost(item.used)} / ${formatCost(item.budget)}`,
}))
return [...members, ...projects].slice(0, 3)
const members = memberAlerts.value.map((item) => ({
id: `member-${item.id}`,
name: item.display_name || item.username,
percent: item.usage_percent,
type: '成员额度',
detail: `${formatCost(item.quota_used)} / ${formatCost(item.quota_limit)}`,
}))
return [...projects, ...members].sort((a, b) => b.percent - a.percent)
})
const visibleAlerts = computed(() => alertItems.value.slice(0, 4))
const budgetedProjects = computed(() => projectBudgets.value.filter((item) => item.has_budget))
function ensureArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []
}
@@ -265,9 +325,11 @@ async function fetchAlerts() {
alertsLoading.value = true
try {
const response: any = await request.get('/tenant/dashboard/budget-alerts')
alertsData.value = response.data?.data || null
memberAlerts.value = ensureArray(response.data?.data?.members)
projectAlerts.value = ensureArray(response.data?.data?.projects)
} catch {
alertsData.value = null
memberAlerts.value = []
projectAlerts.value = []
} finally {
alertsLoading.value = false
}
@@ -280,19 +342,47 @@ async function fetchMemberUsage() {
params: { days: selectedDays.value, limit: 8 },
})
memberUsageData.value = ensureArray(response.data?.data?.list)
memberPrevAvailable.value = response.data?.data?.prev_available !== false
} catch {
memberUsageData.value = []
memberPrevAvailable.value = false
} finally {
memberUsageLoading.value = false
}
}
async function fetchTeamHealth() {
healthLoading.value = true
try {
const response: any = await request.get('/tenant/dashboard/team-health')
teamHealth.value = response.data?.data || null
} catch {
teamHealth.value = null
} finally {
healthLoading.value = false
}
}
async function fetchProjectBudgets() {
budgetLoading.value = true
try {
const response: any = await request.get('/tenant/dashboard/project-budget')
projectBudgets.value = ensureArray(response.data?.data?.list)
} catch {
projectBudgets.value = []
} finally {
budgetLoading.value = false
}
}
function refreshAll() {
void fetchDashboard()
void fetchCharts()
void fetchPrediction()
void fetchAlerts()
void fetchMemberUsage()
void fetchTeamHealth()
void fetchProjectBudgets()
}
watch(selectedDays, () => {
@@ -326,11 +416,10 @@ onBeforeUnmount(() => {
<div class="dashboard-shell">
<section class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="mb-1 text-sm font-medium text-primary-500">{{ authStore.tenant?.name || '租户控制台' }}</p>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 md:text-[28px]">
{{ greeting }},{{ authStore.user?.username || 'Admin' }} <span class="inline-block origin-bottom-right animate-wave">👋</span>
</h1>
<p class="mt-1 text-sm text-slate-400">今天是 {{ formattedDate }},查看团队 API 的最新运行状态。</p>
<p class="mt-1 text-sm text-slate-400">今天是 {{ formattedDate }},以下是整个组织的用量与开支。</p>
</div>
<div class="flex items-center gap-3">
<div v-if="dashboardData" class="rate-pill" title="最近 60 秒滑动窗口实时速率">
@@ -348,69 +437,108 @@ onBeforeUnmount(() => {
</section>
<section v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div v-for="index in 4" :key="index" class="stat-card h-[152px]">
<div class="flex items-center justify-between">
<div class="skeleton h-10 w-10 rounded-xl"></div>
<div class="skeleton h-5 w-16 rounded-full"></div>
</div>
<div class="skeleton mt-4 h-8 w-28"></div>
<div class="skeleton mt-3 h-3.5 w-40"></div>
<div v-for="index in 4" :key="index" class="metric-card">
<div class="metric-head">
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-[1.85rem] w-[1.85rem] rounded-[0.6rem]"></div>
</div>
<div class="skeleton mt-[0.85rem] h-8 w-32 rounded"></div>
<div class="skeleton mt-auto h-3.5 w-40 rounded"></div>
</div>
</section>
<section v-else-if="dashboardData" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<article
v-for="stat in coreStats"
:key="stat.label"
class="stat-card metric-card group"
:key="stat.key"
class="metric-card"
:class="stat.state ? `metric-${stat.state}` : ''"
:style="{ '--metric-color': stat.color, '--metric-soft': stat.soft }"
>
<div class="metric-accent" aria-hidden="true"></div>
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<div class="metric-icon">
<Icon :name="stat.icon" size="md" />
</div>
<p class="truncate text-sm font-semibold text-slate-600">{{ stat.label }}</p>
</div>
<span class="metric-badge">{{ stat.trend }}</span>
</div>
<p class="metric-value" :title="stat.value">{{ stat.value }}</p>
<div class="metric-detail">
<span class="metric-detail-dot" aria-hidden="true"></span>
<span class="truncate">{{ stat.sub }}</span>
</div>
<div class="metric-head">
<p class="metric-label">{{ stat.label }}</p>
<span class="metric-icon"><Icon :name="stat.icon" size="sm" /></span>
</div>
<div class="metric-figure">
<span class="metric-value" :title="stat.value">{{ stat.value }}</span>
<span v-if="stat.delta" class="metric-delta" :class="`delta-${stat.delta.tone}`">{{ stat.delta.text }}</span>
</div>
<p class="metric-sub" :title="stat.sub">{{ stat.sub }}</p>
</article>
</section>
<section class="grid grid-cols-1 gap-5 2xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,.9fr)]">
<TokenTrendChart :data="trendData" :loading="chartsLoading" :days="selectedDays" @change-days="selectedDays = $event" />
<div class="card card-prominent p-5 sm:p-6">
<div class="mb-4 flex items-center justify-between">
<TokenTrendChart
:data="trendData"
:loading="chartsLoading"
:days="selectedDays"
title="消费趋势"
:subtitle="`近 ${selectedDays} 天,按天聚合`"
@change-days="selectedDays = $event"
/>
<div class="card card-prominent flex flex-col">
<div class="flex items-center justify-between border-b border-slate-100/80 px-5 py-4 sm:px-6">
<div>
<h2 class="text-base font-semibold text-slate-900">账户概览</h2>
<p class="mt-0.5 text-xs text-slate-400">余额、成员与密钥状态</p>
<h2 class="text-base font-semibold text-slate-900">需要处理</h2>
<p class="mt-0.5 text-xs text-slate-400">超阈预算与被浪费掉的支出</p>
</div>
<span v-if="!alertsLoading" class="status-dot" :class="alertItems.length ? 'status-warning' : 'status-ok'">{{ alertItems.length ? '需关注' : '运行正常' }}</span>
<span v-if="!alertsLoading" class="status-dot" :class="alertItems.length ? 'status-warning' : 'status-ok'">
{{ alertItems.length ? `${alertItems.length} 项` : '运行正常' }}
</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div v-for="item in accountStats" :key="item.label" class="rounded-2xl border border-white/80 bg-white/55 p-3.5">
<div class="mb-3 flex h-8 w-8 items-center justify-center rounded-xl" :class="[item.background, item.tone]">
<Icon :name="item.icon" size="sm" />
</div>
<p class="text-xs text-slate-400">{{ item.label }}</p>
<p class="mt-1 truncate text-base font-bold" :class="item.tone">{{ item.value }}</p>
<p class="mt-1 truncate text-[11px] text-slate-400">{{ item.description }}</p>
<div class="px-5 py-4 sm:px-6">
<div v-if="alertsLoading" class="space-y-2">
<div v-for="index in 3" :key="index" class="skeleton h-12 rounded-xl"></div>
</div>
</div>
<div v-if="alertItems.length" class="mt-4 space-y-2 border-t border-slate-100 pt-4">
<div v-for="alert in alertItems" :key="alert.id" class="flex items-center gap-3 rounded-xl bg-amber-50/80 px-3 py-2.5">
<Icon name="exclamationTriangle" size="sm" class="flex-shrink-0 text-amber-500" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-slate-700">{{ alert.name }}</p>
<p class="text-[10px] text-slate-400">{{ alert.type }}</p>
<div v-else-if="visibleAlerts.length" class="space-y-2">
<div
v-for="alert in visibleAlerts"
:key="alert.id"
class="flex items-center gap-3 rounded-xl px-3 py-2.5"
:class="alert.percent >= 90 ? 'bg-red-50/80' : 'bg-amber-50/80'"
>
<Icon
name="exclamationTriangle"
size="sm"
class="flex-shrink-0"
:class="alert.percent >= 90 ? 'text-red-500' : 'text-amber-500'"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-slate-700">{{ alert.name }}</p>
<p class="text-[10px] text-slate-400">{{ alert.type }} · {{ alert.detail }}</p>
</div>
<span class="text-xs font-bold tabular-nums" :class="alert.percent >= 90 ? 'text-red-600' : 'text-amber-600'">
{{ alert.percent.toFixed(0) }}%
</span>
</div>
</div>
<p v-else class="py-3 text-center text-xs text-slate-400">暂无超阈的项目预算或成员额度</p>
</div>
<div v-if="dashboardData" class="mt-auto border-t border-slate-100/80 px-5 py-4 sm:px-6">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-slate-700">无效支出</p>
<p class="mt-0.5 text-[11px] text-slate-400">本月失败请求已产生的费用</p>
</div>
<div class="text-right">
<p class="text-base font-bold tabular-nums text-red-500">{{ formatCost(dashboardData.waste.wasted_cost) }}</p>
<p class="mt-0.5 text-[11px] text-slate-400">占本月 {{ dashboardData.waste.share_percent.toFixed(1) }}%</p>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2.5">
<div class="mini-tile">
<p class="text-[11px] text-slate-400">失败请求</p>
<p class="mt-0.5 text-sm font-bold tabular-nums text-slate-700">{{ formatNumber(dashboardData.waste.failed_requests) }}</p>
</div>
<div class="mini-tile">
<p class="text-[11px] text-slate-400">触发重试</p>
<p class="mt-0.5 text-sm font-bold tabular-nums text-slate-700">{{ formatNumber(dashboardData.waste.retried_requests) }}</p>
</div>
<span class="text-xs font-bold text-amber-600">{{ alert.percent }}%</span>
</div>
</div>
</div>
@@ -421,7 +549,9 @@ onBeforeUnmount(() => {
<div class="flex items-center justify-between border-b border-slate-100/80 px-5 py-4 sm:px-6">
<div>
<h2 class="text-base font-semibold text-slate-900">成员用量排行</h2>
<p class="mt-0.5 text-xs text-slate-400">近 {{ selectedDays }} 天团队调用费用</p>
<p class="mt-0.5 text-xs text-slate-400">
近 {{ selectedDays }} 天团队调用费用{{ memberPrevAvailable ? ',含环比' : '' }}
</p>
</div>
<router-link to="/tenant/members" class="text-xs font-medium text-primary-600 transition-colors hover:text-primary-700">查看全部 →</router-link>
</div>
@@ -437,16 +567,41 @@ onBeforeUnmount(() => {
<p class="mt-1 text-xs text-slate-400">成员产生调用后将在这里展示</p>
</div>
<div v-else class="divide-y divide-slate-100/80 px-5 sm:px-6">
<div v-for="(member, index) in memberUsageData" :key="member.user_id" class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-3.5">
<span class="flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold" :class="index < 3 ? 'bg-primary-50 text-primary-600' : 'bg-slate-50 text-slate-400'">{{ index + 1 }}</span>
<div
v-for="(member, index) in memberUsageData"
:key="member.user_id"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-3.5"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold"
:class="index < 3 ? 'bg-primary-50 text-primary-600' : 'bg-slate-50 text-slate-400'"
>{{ index + 1 }}</span>
<div class="flex min-w-0 items-center gap-3">
<div class="member-avatar" :style="{ '--avatar-hue': `${(member.user_id * 43) % 360}` }">{{ (member.display_name || member.username).charAt(0).toUpperCase() }}</div>
<div class="member-avatar" :style="{ '--avatar-hue': `${(member.user_id * 43) % 360}` }">
{{ (member.display_name || member.username).charAt(0).toUpperCase() }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-slate-700">{{ member.display_name || member.username }}</p>
<p class="truncate text-xs text-slate-400">{{ formatNumber(member.requests) }} 次请求 · {{ formatNumber(member.input_tokens + member.output_tokens) }} Token</p>
<div class="flex items-center gap-2">
<p class="truncate text-sm font-medium text-slate-700">{{ member.display_name || member.username }}</p>
<span v-if="member.has_quota && member.quota_percent >= 80" class="quota-flag">
额度 {{ member.quota_percent.toFixed(0) }}%
</span>
</div>
<p class="truncate text-xs text-slate-400">
{{ formatNumber(member.requests) }} 次请求 · {{ formatNumber(member.input_tokens + member.output_tokens) }} Token
</p>
</div>
</div>
<span class="text-sm font-semibold tabular-nums text-slate-700">{{ formatCost(member.total_cost) }}</span>
<div class="flex items-center gap-2.5">
<span
v-if="memberPrevAvailable && member.has_previous"
class="trend-chip"
:class="member.delta_percent > 5 ? 'chip-up' : member.delta_percent < -5 ? 'chip-down' : 'chip-flat'"
>
{{ member.delta_percent > 0 ? '+' : '' }}{{ member.delta_percent.toFixed(1) }}%
</span>
<span class="min-w-[5rem] text-right text-sm font-semibold tabular-nums text-slate-700">{{ formatCost(member.total_cost) }}</span>
</div>
</div>
</div>
</div>
@@ -454,6 +609,90 @@ onBeforeUnmount(() => {
<ModelDistChart :data="modelData" :loading="chartsLoading" />
</section>
<section class="grid grid-cols-1 gap-5 2xl:grid-cols-[minmax(0,1.45fr)_minmax(360px,.9fr)]">
<div class="card card-prominent overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100/80 px-5 py-4 sm:px-6">
<div>
<h2 class="text-base font-semibold text-slate-900">项目预算</h2>
<!-- 预算是累计口径(与 CheckProjectBudget 同源),不随上方时间窗变化 -->
<p class="mt-0.5 text-xs text-slate-400">各项目累计已用与预算上限</p>
</div>
<router-link to="/tenant/projects" class="text-xs font-medium text-primary-600 transition-colors hover:text-primary-700">项目管理 →</router-link>
</div>
<div v-if="budgetLoading" class="space-y-3 p-5 sm:p-6">
<div v-for="index in 4" :key="index" class="skeleton h-12 rounded-xl"></div>
</div>
<div v-else-if="budgetedProjects.length === 0" class="flex min-h-56 flex-col items-center justify-center px-6 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 text-primary-400">
<Icon name="project" size="lg" />
</div>
<p class="mt-3 text-sm font-medium text-slate-600">暂无设置预算的项目</p>
<p class="mt-1 text-xs text-slate-400">为项目设置预算后可在这里跟踪消耗</p>
</div>
<div v-else class="divide-y divide-slate-100/80 px-5 sm:px-6">
<div v-for="project in budgetedProjects" :key="project.id" class="py-3.5">
<div class="flex items-center justify-between gap-3">
<span class="truncate text-sm font-medium text-slate-700">{{ project.name }}</span>
<span class="flex-shrink-0 text-xs tabular-nums text-slate-500">
{{ formatCost(project.used) }} / {{ formatCost(project.budget) }}
<b
class="ml-1.5"
:class="project.usage_percent >= 90 ? 'text-red-500' : project.usage_percent >= 70 ? 'text-amber-500' : 'text-slate-600'"
>{{ project.usage_percent.toFixed(0) }}%</b>
</span>
</div>
<div class="progress mt-2">
<div
class="progress-bar"
:class="`bar-${toneOf(project.usage_percent)}`"
:style="{ width: `${Math.min(project.usage_percent, 100)}%` }"
></div>
</div>
</div>
</div>
</div>
<div class="card card-prominent p-5 sm:p-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">团队运行质量</h2>
<p class="mt-0.5 text-xs text-slate-400">本月整体可靠性与性能</p>
</div>
<span
v-if="teamHealth"
class="status-dot"
:class="teamHealth.success_rate >= 0.99 ? 'status-ok' : teamHealth.success_rate >= 0.95 ? 'status-warning' : 'status-danger'"
>
{{ teamHealth.success_rate >= 0.99 ? '运行正常' : teamHealth.success_rate >= 0.95 ? '需关注' : '需处理' }}
</span>
</div>
<div v-if="healthLoading" class="grid grid-cols-2 gap-3">
<div v-for="index in 4" :key="index" class="skeleton h-[72px] rounded-2xl"></div>
</div>
<div v-else-if="teamHealth && teamHealth.total_requests > 0" class="grid grid-cols-2 gap-3">
<div class="mini-tile">
<p class="text-[11px] text-slate-400">调用成功率</p>
<p class="mt-1 text-base font-bold tabular-nums text-emerald-600">{{ (teamHealth.success_rate * 100).toFixed(1) }}%</p>
</div>
<div class="mini-tile">
<p class="text-[11px] text-slate-400">P95 响应</p>
<p class="mt-1 text-base font-bold tabular-nums text-slate-700">{{ formatMs(teamHealth.p95_ms) }}</p>
</div>
<div class="mini-tile">
<p class="text-[11px] text-slate-400">缓存命中率</p>
<p class="mt-1 text-base font-bold tabular-nums text-cyan-600">{{ (teamHealth.cache_hit_ratio * 100).toFixed(1) }}%</p>
</div>
<div class="mini-tile">
<p class="text-[11px] text-slate-400">平均首 Token</p>
<p class="mt-1 text-base font-bold tabular-nums text-slate-700">{{ formatMs(teamHealth.avg_first_token_ms) }}</p>
</div>
</div>
<p v-else class="py-8 text-center text-xs text-slate-400">本月暂无调用记录</p>
</div>
</section>
<div v-if="!loading && !dashboardData" class="card">
<div class="empty-state">
<Icon name="chart" size="xl" class="empty-state-icon" />
@@ -532,81 +771,174 @@ onBeforeUnmount(() => {
background: #e2e8f0;
}
/* ── 指标卡 ──────────────────────────────────────────────
设计取舍:
1) 去掉顶部渐变装饰条——它不承载任何信息,四张卡并排时四条彩线互相抢;
2) 去掉右上角徽标——「本月消费」配「本月」徽标是同义重复,位置让给数值;
3) 颜色只用来表达状态:常态下仅图标底色带一点品牌色相,只有真正需要
关注的卡(余额将耗尽、额度将用满)才亮起左侧状态条与状态色数值。
这样四张卡平时是安静的,出问题的那张才会跳出来。 */
.metric-card {
position: relative;
display: flex;
min-height: 152px;
padding: 1.125rem 1.25rem;
flex-direction: column;
overflow: hidden;
padding: 1.15rem 1.25rem 1.2rem;
border: 1px solid var(--glass-border);
border-radius: var(--radius-card);
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
box-shadow: var(--shadow-card);
transition: transform 220ms ease, box-shadow 220ms ease;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.metric-card {
background: rgba(255, 255, 255, 0.97);
}
}
.metric-card:hover {
transform: translateY(-3px);
box-shadow: 0 20px 45px rgba(81, 94, 143, 0.14);
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(81, 94, 143, 0.13);
}
.metric-accent {
/* 状态条:四张卡都有,常态是极淡的中性色,只有进入告警态才变色。
之前是"告警才出现",结果一排卡里凭空多出一根线,读起来像没对齐的缺陷
而不是提示——现在它是恒定的结构元素在变色,异常感消失,信号仍在。 */
.metric-card::before {
position: absolute;
top: 0;
right: 1.25rem;
left: 1.25rem;
height: 2px;
border-radius: 0 0 9999px 9999px;
background: linear-gradient(90deg, transparent, var(--metric-color), transparent);
opacity: 0.7;
top: 1.15rem;
bottom: 1.2rem;
left: 0;
width: 3px;
border-radius: 0 3px 3px 0;
background: rgba(148, 163, 184, 0.22);
content: '';
transition: background 220ms ease;
}
.metric-icon {
.metric-warn::before {
background: #f59e0b;
}
.metric-crit::before {
background: #ef4444;
}
.metric-head {
display: flex;
height: 2.5rem;
width: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 0.75rem;
background: var(--metric-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
color: var(--metric-color);
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.metric-badge {
flex-shrink: 0;
border: 1px solid color-mix(in srgb, var(--metric-color) 20%, transparent);
border-radius: 9999px;
background: var(--metric-soft);
padding: 0.25rem 0.5rem;
color: var(--metric-color);
font-size: 0.625rem;
font-weight: 700;
}
.metric-value {
margin-top: 0.875rem;
.metric-label {
min-width: 0;
overflow: hidden;
color: #172033;
font-size: 1.75rem;
font-weight: 750;
font-variant-numeric: tabular-nums;
line-height: 1;
color: #64748b;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.01em;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-detail {
/* 图标退到右上做定位锚点,不再与标签争夺左侧起始位 */
.metric-icon {
display: flex;
min-width: 0;
height: 1.85rem;
width: 1.85rem;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
color: #94a3b8;
font-size: 0.6875rem;
justify-content: center;
border-radius: 0.6rem;
background: var(--metric-soft);
color: var(--metric-color);
}
.metric-detail-dot {
height: 0.375rem;
width: 0.375rem;
.metric-figure {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-top: 0.85rem;
min-width: 0;
}
.metric-value {
min-width: 0;
overflow: hidden;
color: #172033;
font-size: 1.875rem;
font-weight: 720;
font-variant-numeric: tabular-nums;
letter-spacing: -0.025em;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-crit .metric-value {
color: #dc2626;
}
/* 环比是信息不是装饰,所以紧贴数值而不是塞进脚注 */
.metric-delta {
flex-shrink: 0;
border-radius: 9999px;
background: var(--metric-color);
border-radius: 0.35rem;
padding: 0.1rem 0.35rem;
font-size: 0.6875rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.delta-up {
background: #fef2f2;
color: #dc2626;
}
.delta-down {
background: #ecfdf5;
color: #059669;
}
.delta-flat {
background: #f1f5f9;
color: #94a3b8;
}
/* 脚注贴底,保证四张卡在有无进度条时高度一致 */
.metric-sub {
margin-top: auto;
overflow: hidden;
padding-top: 0.8rem;
color: #94a3b8;
font-size: 0.6875rem;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 进度条语义色,配合全局 .progress / .progress-bar 使用 */
.bar-ok {
background: linear-gradient(90deg, #2dd4bf, #0d9488);
}
.bar-warn {
background: linear-gradient(90deg, #fbbf24, #f59e0b);
}
.bar-crit {
background: linear-gradient(90deg, #f87171, #ef4444);
}
.mini-tile {
border: 1px solid rgba(255, 255, 255, 0.85);
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.6);
padding: 0.7rem 0.8rem;
}
.member-avatar {
@@ -624,6 +956,42 @@ onBeforeUnmount(() => {
font-weight: 700;
}
.quota-flag {
flex-shrink: 0;
border-radius: 0.35rem;
background: #fffbeb;
padding: 0.1rem 0.35rem;
color: #d97706;
font-size: 0.625rem;
font-weight: 700;
white-space: nowrap;
}
.trend-chip {
flex-shrink: 0;
border-radius: 0.35rem;
padding: 0.15rem 0.4rem;
font-size: 0.6875rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chip-up {
background: #fef2f2;
color: #dc2626;
}
.chip-down {
background: #ecfdf5;
color: #10b981;
}
.chip-flat {
background: #f1f5f9;
color: #94a3b8;
}
.status-dot {
display: inline-flex;
align-items: center;
@@ -652,6 +1020,11 @@ onBeforeUnmount(() => {
color: #f59e0b;
}
.status-danger {
background: #fef2f2;
color: #ef4444;
}
@keyframes wave {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(14deg); }
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatNumber } from '@/utils/renderUtils'
import { ref, computed, onMounted } from 'vue'
import { NInput, NCheckbox } from 'naive-ui'
import { useRouter, useRoute } from 'vue-router'
@@ -121,12 +122,6 @@ const isLocked = computed(() => {
return new Date(member.value.locked_until).getTime() > Date.now()
})
function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(n)
}
function formatDate(d: string | null | undefined): string {
if (!d) return '--'
return d.replace('T', ' ').substring(0, 16)
@@ -33,6 +33,7 @@ interface Project {
active_keys: number
total_keys: number
month_cost: number
budget_used: number
month_requests: number
}
const project = ref<Partial<Project>>({})
@@ -171,7 +172,10 @@ const relayModeLabel: Record<string, string> = {
const budgetUsage = computed(() => {
const budget = project.value.budget ? Number(project.value.budget) : 0
if (!budget || budget <= 0) return 0
const cost = project.value.month_cost || 0
// 必须用 budget_used(累计实扣,与 CheckProjectBudget 同源),不能用 month_cost:
// 后者是「本月」「列表价」,拿它除以「累计」预算得到的百分比没有意义,
// 会出现页面显示 20%、项目其实已因超预算被停用的错位。
const cost = project.value.budget_used || 0
return Math.min(Math.round((cost / budget) * 10000) / 100, 100)
})