commit
b25d053f7b
67 files changed
+5276
-961
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"` // 结果描述
|
||||
}
|
||||
@@ -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 数量,控制角标转红"`
|
||||
}
|
||||
@@ -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
@@ -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:"是否设置了预算"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 端口建议开启"},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 是否有错误
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? '▲' : '▼' }} {{ 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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user