diff --git a/Makefile b/Makefile index bf792ed..055586f 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,9 @@ tidy: # GoFrame code generation ctrl: - gf gen ctrl + # 必须带 -m:本项目 controller 按 api 源文件合并生成, + # 裸 gf gen ctrl 会按方法拆分并与现有合并文件产生重复方法定义 + gf gen ctrl -m dao: gf gen dao diff --git a/api/admin/admin.go b/api/admin/admin.go index e47a7c4..444604c 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -246,6 +246,7 @@ type IAdminV1 interface { AdminSettingsGet(ctx context.Context, req *v1.AdminSettingsGetReq) (res *v1.AdminSettingsGetRes, err error) AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error) AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error) + AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error) TaskList(ctx context.Context, req *v1.TaskListReq) (res *v1.TaskListRes, err error) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (res *v1.TaskDetailRes, err error) TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (res *v1.TaskCancelRes, err error) @@ -281,4 +282,6 @@ type IAdminV1 interface { UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (res *v1.UsageLogCleanupCreateRes, err error) UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (res *v1.UsageLogCleanupListRes, err error) UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (res *v1.UsageLogCleanupCancelRes, err error) + AdminWorkbenchSummary(ctx context.Context, req *v1.AdminWorkbenchSummaryReq) (res *v1.AdminWorkbenchSummaryRes, err error) + AdminWorkbenchBadge(ctx context.Context, req *v1.AdminWorkbenchBadgeReq) (res *v1.AdminWorkbenchBadgeRes, err error) } diff --git a/api/admin/v1/settings.go b/api/admin/v1/settings.go index cfde1b9..2ba84cf 100644 --- a/api/admin/v1/settings.go +++ b/api/admin/v1/settings.go @@ -70,3 +70,25 @@ type AdminStorageTestRes struct { ElapsedMs int64 `json:"elapsed_ms"` // 往返耗时(毫秒) Message string `json:"message"` // 结果描述 } + +// AdminEmailTestReq 发送测试邮件,验证 SMTP 配置连通性。 +// 携带表单中的邮件配置(可含未保存的改动);字段为空或为掩码 "******" 时后端回落到 +// 已保存的值,从而支持「未保存直接测试」,与对象存储测试的行为保持一致。 +type AdminEmailTestReq struct { + g.Meta `path:"/settings/email/test" method:"post" mime:"json" tags:"管理后台-系统设置" summary:"发送测试邮件"` + Host string `json:"email_smtp_host" dc:"SMTP 服务器,空则回落已保存值"` + Port int `json:"email_smtp_port" dc:"SMTP 端口,0 则回落已保存值"` + Username string `json:"email_smtp_username" dc:"SMTP 用户名,空则回落已保存值"` + Password string `json:"email_smtp_password" dc:"SMTP 密码,空/掩码回落已保存值"` + From string `json:"email_smtp_from" dc:"发件人地址,空则回落已保存值"` + FromName string `json:"email_smtp_from_name" dc:"发件人显示名,空则回落已保存值/站点名称"` + UseTLS *bool `json:"email_smtp_tls" dc:"是否启用 TLS,不传则回落已保存值"` + To string `json:"to" dc:"收件人,留空则发送给当前登录管理员的邮箱"` +} + +type AdminEmailTestRes struct { + Sent bool `json:"sent"` // 是否投递成功 + Recipient string `json:"recipient"` // 实际收件人 + ElapsedMs int64 `json:"elapsed_ms"` // 耗时(毫秒) + Message string `json:"message"` // 结果描述 +} diff --git a/api/admin/v1/workbench.go b/api/admin/v1/workbench.go new file mode 100644 index 0000000..9b84109 --- /dev/null +++ b/api/admin/v1/workbench.go @@ -0,0 +1,106 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// ============================================================ +// 工作台(运营收件箱) +// +// 与仪表盘(/dashboard)的分工:仪表盘回答「现在怎么样」,工作台回答「我该做什么」。 +// 只有满足「有人要处理 + 不处理会恶化 + 能被清空归零」三条的事才进工作台, +// 纯统计一律留在仪表盘。 +// ============================================================ + +// === 严重度 / 域 常量(与前端约定,勿随意改动字面量)=== +const ( + WorkbenchSeverityP0 = "p0" // 紧急:正在影响服务可用性或资金安全 + WorkbenchSeverityP1 = "p1" // 今日:当天要处理 + WorkbenchSeverityP2 = "p2" // 本周:可排期 + + WorkbenchDomainAvailability = "availability" // 服务可用性 + WorkbenchDomainMoney = "money" // 资金安全 + WorkbenchDomainCustomer = "customer" // 客户运营 + WorkbenchDomainSystem = "system" // 系统安全 +) + +// WorkbenchItem 一条待办。 +// +// Key 是稳定可重算的业务标识(如 model_no_channel:claude-sonnet-4), +// 供前端做列表 key 与去重 —— 同一异常在两次收集之间必须算出同一个 Key, +// 所以禁止把时间戳、随机数或行 ID 拼进 Key(除非该行本身就是待办对象)。 +type WorkbenchItem struct { + Key string `json:"key" dc:"待办唯一标识(稳定可重算,用于静音匹配)"` + Severity string `json:"severity" dc:"严重度:p0=紧急 / p1=今日 / p2=本周"` + Domain string `json:"domain" dc:"所属域:availability / money / customer / system"` + Title string `json:"title" dc:"标题(一句话说清是什么问题)"` + Desc string `json:"desc" dc:"补充说明(影响面、判断依据)"` + // OccurredAt 为空表示该待办是聚合计数(如「5 条反馈待处理」)而非单点事件。 + OccurredAt string `json:"occurred_at" dc:"发生时间(RFC3339),聚合类待办为空"` + ActionText string `json:"action_text" dc:"跳转按钮文案"` + ActionRoute string `json:"action_route" dc:"跳转目标前端路由名"` + ActionQuery map[string]string `json:"action_query" dc:"跳转时附带的查询参数(自动带上筛选条件)"` +} + +// WorkbenchMetric 首屏关键数字。 +type WorkbenchMetric struct { + Key string `json:"key" dc:"指标标识"` + Label string `json:"label" dc:"展示名"` + Value float64 `json:"value" dc:"数值"` + // Unit 决定前端格式化方式:money=本位币金额 / percent=百分比 / count=计数 + Unit string `json:"unit" dc:"单位类型:money / percent / count"` + // Growth 为 nil 表示该指标没有同比口径(如「余额告警租户数」) + Growth *float64 `json:"growth" dc:"环比变化百分比;null 表示无对比口径"` + Sub string `json:"sub" dc:"副标题(说明对比口径或统计窗口)"` +} + +// WorkbenchDomainStat 分域计数。 +type WorkbenchDomainStat struct { + Domain string `json:"domain"` + Total int `json:"total" dc:"该域待办总数"` + Urgent int `json:"urgent" dc:"该域 P0 数量"` +} + +// WorkbenchModelAvail 模型可用渠道速览。 +type WorkbenchModelAvail struct { + Model string `json:"model"` + Available int `json:"available" dc:"熔断均为 CLOSED 的可用渠道数"` + Total int `json:"total" dc:"承载该模型的 active 渠道数"` +} + +// WorkbenchBreaker 正在熔断的渠道×模型。 +type WorkbenchBreaker struct { + ChannelId int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + Model string `json:"model"` + ChannelLevel bool `json:"channel_level" dc:"true=渠道级熔断(该渠道所有模型受影响);false=仅该模型被隔离"` + HalfOpen bool `json:"half_open" dc:"是否处于半开探测态"` +} + +// === 工作台汇总 === + +type AdminWorkbenchSummaryReq struct { + g.Meta `path:"/workbench/summary" method:"get" mime:"json" tags:"管理后台-工作台" summary:"工作台汇总"` +} + +type AdminWorkbenchSummaryRes struct { + Metrics []WorkbenchMetric `json:"metrics" dc:"首屏关键数字"` + Items []WorkbenchItem `json:"items" dc:"待办列表(已按 p0→p1→p2 排序,已剔除静音项)"` + Domains []WorkbenchDomainStat `json:"domains" dc:"分域计数"` + Models []WorkbenchModelAvail `json:"models" dc:"模型可用渠道速览"` + Breakers []WorkbenchBreaker `json:"breakers" dc:"正在熔断的渠道×模型"` + GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"` +} + +// === 菜单红点 === + +// AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。 +// 与 summary 共用同一份缓存,不会额外压库。 +type AdminWorkbenchBadgeReq struct { + g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"工作台菜单角标计数"` +} + +type AdminWorkbenchBadgeRes struct { + Total int `json:"total" dc:"待办总数,仅挂在工作台菜单角标"` + Urgent int `json:"urgent" dc:"P0 数量,控制角标转红"` +} diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index 59be8bb..a67deac 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -44,6 +44,8 @@ type ITenantV1 interface { TenantBalancePrediction(ctx context.Context, req *v1.TenantBalancePredictionReq) (res *v1.TenantBalancePredictionRes, err error) TenantBudgetAlerts(ctx context.Context, req *v1.TenantBudgetAlertsReq) (res *v1.TenantBudgetAlertsRes, err error) TenantMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (res *v1.TenantMemberUsageRankingRes, err error) + TenantTeamHealth(ctx context.Context, req *v1.TenantTeamHealthReq) (res *v1.TenantTeamHealthRes, err error) + TenantProjectBudget(ctx context.Context, req *v1.TenantProjectBudgetReq) (res *v1.TenantProjectBudgetRes, err error) TenantSendCode(ctx context.Context, req *v1.TenantSendCodeReq) (res *v1.TenantSendCodeRes, err error) TenantResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (res *v1.TenantResetPasswordRes, err error) TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error) diff --git a/api/tenant/v1/dashboard.go b/api/tenant/v1/dashboard.go index a1083a7..3dcbab6 100644 --- a/api/tenant/v1/dashboard.go +++ b/api/tenant/v1/dashboard.go @@ -4,20 +4,40 @@ import "github.com/gogf/gf/v2/frame/g" // === 租户仪表盘 === +// ---------- 1. 概览 ---------- + type TenantDashboardReq struct { g.Meta `path:"/dashboard" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"仪表盘概览"` } type TenantDashboardRes struct { - Today map[string]any `json:"today"` - Month map[string]any `json:"month"` - Wallet map[string]any `json:"wallet"` - Rpm int64 `json:"rpm" dc:"最近60秒滑动窗口请求数(本租户)"` - Tpm int64 `json:"tpm" dc:"最近60秒滑动窗口token数(本租户)"` - ActiveKeys int `json:"active_keys"` - MemberCount int `json:"member_count"` + Today map[string]any `json:"today"` + Month map[string]any `json:"month"` + Wallet map[string]any `json:"wallet"` + Rpm int64 `json:"rpm" dc:"最近60秒滑动窗口请求数(本租户)"` + Tpm int64 `json:"tpm" dc:"最近60秒滑动窗口token数(本租户)"` + ActiveKeys int `json:"active_keys"` + MemberCount int `json:"member_count"` + CostTrend TenantCostTrend `json:"cost_trend" dc:"本月消费与上月同期对比"` + Waste TenantWasteStat `json:"waste" dc:"本月失败请求造成的无效支出"` } +type TenantCostTrend struct { + CurrentCost float64 `json:"current_cost" dc:"本月至今消费"` + PreviousCost float64 `json:"previous_cost" dc:"上月同期(相同已过天数)消费"` + DeltaPercent float64 `json:"delta_percent" dc:"环比变化百分比;上月同期为 0 时记 0"` + HasPrevious bool `json:"has_previous" dc:"上月同期是否有数据,false 时前端不显示环比"` +} + +type TenantWasteStat struct { + WastedCost float64 `json:"wasted_cost" dc:"本月失败请求已产生的费用"` + SharePercent float64 `json:"share_percent" dc:"占本月总消费的百分比"` + FailedRequests int64 `json:"failed_requests" dc:"本月失败请求数"` + RetriedRequests int64 `json:"retried_requests" dc:"本月触发过重试的请求数"` +} + +// ---------- 2. 趋势与分布 ---------- + type TenantTokenTrendsReq struct { g.Meta `path:"/dashboard/token-trends" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"Token趋势"` Days int `json:"days" in:"query" d:"7"` @@ -36,6 +56,8 @@ type TenantModelDistributionRes struct { List []map[string]any `json:"list"` } +// ---------- 3. 余额预测 ---------- + type TenantBalancePredictionReq struct { g.Meta `path:"/dashboard/balance-prediction" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"余额预测"` } @@ -49,15 +71,37 @@ type TenantBalancePredictionRes struct { Message *string `json:"message,omitempty"` } +// ---------- 4. 预算告警 ---------- + type TenantBudgetAlertsReq struct { g.Meta `path:"/dashboard/budget-alerts" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"预算告警"` } type TenantBudgetAlertsRes struct { - Members []map[string]any `json:"members"` - Projects []map[string]any `json:"projects"` + Members []TenantMemberAlert `json:"members"` + Projects []TenantProjectAlert `json:"projects"` } +type TenantMemberAlert struct { + Id int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + QuotaLimit float64 `json:"quota_limit"` + QuotaUsed float64 `json:"quota_used"` + UsagePercent float64 `json:"usage_percent"` + NextResetAt string `json:"next_reset_at,omitempty" dc:"周期额度的下次重置时间"` +} + +type TenantProjectAlert struct { + Id int64 `json:"id"` + Name string `json:"name"` + Budget float64 `json:"budget"` + Used float64 `json:"used"` + UsagePercent float64 `json:"usage_percent"` +} + +// ---------- 5. 成员用量排行 ---------- + type TenantMemberUsageRankingReq struct { g.Meta `path:"/dashboard/member-usage-ranking" method:"get" mime:"json" tags:"租户控制台-仪表盘" summary:"成员用量排名"` Days int `json:"days" in:"query" d:"7"` @@ -65,5 +109,54 @@ type TenantMemberUsageRankingReq struct { } type TenantMemberUsageRankingRes struct { - List []map[string]any `json:"list"` + List []TenantMemberUsageItem `json:"list"` + PrevAvailable bool `json:"prev_available" dc:"对比周期是否仍在用量日志保留期内;false 时前端应隐藏环比列"` +} + +type TenantMemberUsageItem struct { + UserId int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalCost float64 `json:"total_cost"` + DeltaPercent float64 `json:"delta_percent" dc:"与上一同长周期相比的消费变化百分比"` + HasPrevious bool `json:"has_previous" dc:"上一周期是否有数据,false 时前端不显示环比"` + QuotaPercent float64 `json:"quota_percent" dc:"个人额度使用百分比;未设额度时为 0"` + HasQuota bool `json:"has_quota" dc:"是否设置了个人额度"` +} + +// ---------- 6. 团队运行质量 ---------- + +type TenantTeamHealthReq struct { + g.Meta `path:"/dashboard/team-health" method:"get" mime:"application/json" tags:"租户控制台-仪表盘" summary:"团队运行质量"` +} + +type TenantTeamHealthRes struct { + TotalRequests int64 `json:"total_requests" dc:"本月请求总数"` + SuccessRate float64 `json:"success_rate" dc:"成功率(0-1)"` + P95Ms float64 `json:"p95_ms" dc:"成功请求的 P95 延迟"` + AvgFirstTokenMs float64 `json:"avg_first_token_ms" dc:"平均首 Token 延迟"` + CacheHitRatio float64 `json:"cache_hit_ratio" dc:"缓存命中率(0-1)"` + CacheReadTokens int64 `json:"cache_read_tokens" dc:"缓存命中的 Token 数"` +} + +// ---------- 7. 项目预算 ---------- + +type TenantProjectBudgetReq struct { + g.Meta `path:"/dashboard/project-budget" method:"get" mime:"application/json" tags:"租户控制台-仪表盘" summary:"项目预算使用情况"` +} + +type TenantProjectBudgetRes struct { + List []TenantProjectBudgetItem `json:"list"` +} + +type TenantProjectBudgetItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + Budget float64 `json:"budget" dc:"累计预算上限;0 表示不限"` + Used float64 `json:"used" dc:"累计已用(与预算执行同源,取自 bil_transactions)"` + UsagePercent float64 `json:"usage_percent" dc:"已用百分比;未设预算时为 0"` + HasBudget bool `json:"has_budget" dc:"是否设置了预算"` } diff --git a/api/tenant/v1/personal_dashboard.go b/api/tenant/v1/personal_dashboard.go index 85ecd44..0d25135 100644 --- a/api/tenant/v1/personal_dashboard.go +++ b/api/tenant/v1/personal_dashboard.go @@ -11,13 +11,14 @@ type PersonalDashboardReq struct { } type PersonalDashboardRes struct { - Today PersonalDayStats `json:"today"` - Month PersonalDayStats `json:"month"` - ErrorRate PersonalErrorRate `json:"error_rate"` - Latency PersonalLatency `json:"latency"` - Cache PersonalCache `json:"cache"` - RequestTypes []PersonalReqTypeItem `json:"request_types"` - Quota *PersonalQuotaStatus `json:"quota,omitempty"` + Today PersonalDayStats `json:"today"` + Month PersonalDayStats `json:"month"` + ErrorRate PersonalErrorRate `json:"error_rate"` + Latency PersonalLatency `json:"latency"` + Cache PersonalCache `json:"cache"` + RequestTypes []PersonalReqTypeItem `json:"request_types"` + Quota *PersonalQuotaStatus `json:"quota,omitempty"` + RecentFailures []PersonalFailureItem `json:"recent_failures" dc:"本月最近的失败调用,最多 5 条"` } type PersonalDayStats struct { @@ -49,6 +50,7 @@ type PersonalCache struct { CacheReadTokens int64 `json:"cache_read_tokens"` TotalInputTokens int64 `json:"total_input_tokens"` HitRatio float64 `json:"hit_ratio"` + SavedCost float64 `json:"saved_cost" dc:"缓存命中相对全价输入所省下的费用(估算)"` } type PersonalReqTypeItem struct { @@ -67,6 +69,13 @@ type PersonalQuotaStatus struct { NextResetAt string `json:"next_reset_at,omitempty"` } +type PersonalFailureItem struct { + Status string `json:"status" dc:"error / timeout / cancelled"` + ModelName string `json:"model_name"` + ErrorMessage string `json:"error_message"` + CreatedAt string `json:"created_at"` +} + // ---------- 2. Token Trends ---------- type PersonalTokenTrendsReq struct { diff --git a/api/tenant/v1/project_tenant.go b/api/tenant/v1/project_tenant.go index ef95dbd..355b344 100644 --- a/api/tenant/v1/project_tenant.go +++ b/api/tenant/v1/project_tenant.go @@ -69,7 +69,8 @@ type TenantProjectGetRes struct { CreatedBy int64 `json:"created_by"` ActiveKeys int `json:"active_keys"` TotalKeys int `json:"total_keys"` - MonthCost float64 `json:"month_cost"` + MonthCost float64 `json:"month_cost" dc:"本月用量成本(列表价口径,非预算口径)"` + BudgetUsed float64 `json:"budget_used" dc:"预算已用(累计实扣,与预算强制执行同源)"` MonthRequests int64 `json:"month_requests"` } diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index e7a7d35..7a1498d 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -815,3 +815,29 @@ HMGET 对不存在的 key/字段返回 Redis nil,但 gredis 把它们包装成 - 同一份状态有多个读取方时(调度用 gconv、管理后台用 Sscanf),**解析方式必须统一**,否则同一数据在不同页面表现不同,故障期误导排查方向。 排查信号:某项指标「本该回落默认值却全是 0」;同一数据 UI 显示"无数据"而后台计算按 0 处理;`grep -n 'IsNil()' ` 命中 Redis 读取路径。 + +### 2026-08-31:`g.Cfg().MustGet` 读取不存在的配置段静默返回空值,告警邮件长期发送失败 + +**问题**:监控告警的邮件通知从上线起就没成功发出过,`ntf_send_log` 里堆满 `status=failed` 的记录,错误信息是 SMTP 连接失败——但管理后台「系统设置 → 邮件配置」里 SMTP 配置完全正常,验证码等其他邮件都能正常投递。 + +**原因**:`internal/logic/monitor/alert_notify.go` 的 `sendAlertEmailToAdmins` 自己拼 `EmailConfig`,读的是配置文件: + +```go +sender := common.NewEmailSender(&common.EmailConfig{ + Host: g.Cfg().MustGet(ctx, "email.smtp.host").String(), // ← 配置文件里根本没有 email 段 + Port: g.Cfg().MustGet(ctx, "email.smtp.port").Int(), + ... +}) +``` + +`manifest/config/config.yaml` 中并不存在 `email` 段。`MustGet` 的 `Must` 只针对**读取过程出错**(配置文件损坏等)才 panic,**「键不存在」不是错误**:它返回一个空 `*gvar.Var`,`.String()` 得到 `""`、`.Int()` 得到 `0`。于是 Host 为空、Port 为 0,每封告警邮件都要走满 3 次重试才失败,而且失败原因看起来像"SMTP 服务器有问题",与真正的原因(配置压根没读到)南辕北辙。 + +**修复**:改为与其余三条发信链路一致,走设置注册表 `common.EmailConfigFromOptions(ctx)`(数据源 `sys_options`),配置缺失时返回明确错误并直接跳过发送。至此邮件配置的唯一来源是数据库,配置文件不再参与。 + +**正确做法(通用规则)**: + +- **`MustGet` 不保证键存在**——它只保证读取过程无错。对必填配置要显式判空并给出可读错误,不要让空值一路流进业务逻辑变成"连不上/额度为 0/超时 0 秒"这类误导性症状。 +- **运行期可由管理员调整的配置一律走设置注册表 `common.Config()`(`sys_options`),不要放配置文件**:配置文件只承载启动期基础设施配置(数据库、Redis、监听端口)。两套来源并存时,改了后台却不生效、或某条链路读到空配置,是最难排查的一类问题。 +- 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。 + +排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。 diff --git a/internal/controller/admin/admin_v1_settings.go b/internal/controller/admin/admin_v1_settings.go index efbdf1b..17ef01b 100644 --- a/internal/controller/admin/admin_v1_settings.go +++ b/internal/controller/admin/admin_v1_settings.go @@ -19,3 +19,6 @@ func (c *ControllerV1) AdminSettingsUpdate(ctx context.Context, req *v1.AdminSet func (c *ControllerV1) AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error) { return service.Admin().TestStorageConfig(ctx, req) } +func (c *ControllerV1) AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error) { + return service.Admin().TestEmailConfig(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_workbench.go b/internal/controller/admin/admin_v1_workbench.go new file mode 100644 index 0000000..a444679 --- /dev/null +++ b/internal/controller/admin/admin_v1_workbench.go @@ -0,0 +1,15 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AdminWorkbenchSummary(ctx context.Context, req *v1.AdminWorkbenchSummaryReq) (res *v1.AdminWorkbenchSummaryRes, err error) { + return service.Admin().GetWorkbenchSummary(ctx, req) +} +func (c *ControllerV1) AdminWorkbenchBadge(ctx context.Context, req *v1.AdminWorkbenchBadgeReq) (res *v1.AdminWorkbenchBadgeRes, err error) { + return service.Admin().GetWorkbenchBadges(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_dashboard.go b/internal/controller/tenant/tenant_v1_dashboard.go index d500943..afa571d 100644 --- a/internal/controller/tenant/tenant_v1_dashboard.go +++ b/internal/controller/tenant/tenant_v1_dashboard.go @@ -25,3 +25,9 @@ func (c *ControllerV1) TenantBudgetAlerts(ctx context.Context, req *v1.TenantBud func (c *ControllerV1) TenantMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (res *v1.TenantMemberUsageRankingRes, err error) { return service.Tenant().GetMemberUsageRanking(ctx, req) } +func (c *ControllerV1) TenantTeamHealth(ctx context.Context, req *v1.TenantTeamHealthReq) (res *v1.TenantTeamHealthRes, err error) { + return service.Tenant().TeamHealth(ctx, req) +} +func (c *ControllerV1) TenantProjectBudget(ctx context.Context, req *v1.TenantProjectBudgetReq) (res *v1.TenantProjectBudgetRes, err error) { + return service.Tenant().ProjectBudget(ctx, req) +} diff --git a/internal/dispatchadapter/catalog.go b/internal/dispatchadapter/catalog.go index 26386d3..e1eeb94 100644 --- a/internal/dispatchadapter/catalog.go +++ b/internal/dispatchadapter/catalog.go @@ -216,6 +216,59 @@ func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState { return out } +// ModelAvail 单个模型的渠道可用性视图(工作台「零可用渠道」检测用)。 +// Total 为目录快照中承载该模型的 active 渠道数(disabled / 无 enabled 能力的渠道不在快照里), +// Available 为其中渠道级与模型级熔断均为 CLOSED 的渠道数。 +// Available=0 且 Total>0 即「配了渠道但一条都用不了」——最容易被大盘总量掩盖的静默故障。 +type ModelAvail struct { + Available int + Total int + Breaking []ModelBreak +} + +// ModelBreak 一条正在熔断的渠道×模型记录。 +type ModelBreak struct { + ChannelID int64 + ChannelName string + Model string + // ChannelLevel 为 true 表示渠道级熔断(该渠道所有模型都受影响), + // false 表示仅该模型被隔离(模型级熔断)。 + ChannelLevel bool + HalfOpen bool // 处于半开探测态 +} + +// ModelAvailability 返回目录快照中每个模型的渠道可用性与正在熔断的渠道×模型列表。 +// 目录未初始化时返回 nil,调用方按「无数据」处理,不要当成故障。 +// 并发安全:current 为 atomic.Pointer,Load() 取到的快照不可变,无锁。 +func (c *Catalog) ModelAvailability() map[string]ModelAvail { + idx := c.current.Load() + if idx == nil { + return nil + } + out := make(map[string]ModelAvail, len(idx.byModel)) + for model, chans := range idx.byModel { + av := ModelAvail{Total: len(chans)} + for _, ch := range chans { + chOpen := ch.Breaker != dispatch.BreakerClosed + mdOpen := ch.ModelBreaker != dispatch.BreakerClosed + if !chOpen && !mdOpen { + av.Available++ + continue + } + av.Breaking = append(av.Breaking, ModelBreak{ + ChannelID: ch.ID, + ChannelName: ch.Name, + Model: model, + ChannelLevel: chOpen, + HalfOpen: (chOpen && ch.Breaker == dispatch.BreakerHalfOpen) || + (!chOpen && ch.ModelBreaker == dispatch.BreakerHalfOpen), + }) + } + out[model] = av + } + return out +} + // Invalidate 主动触发一次刷新(渠道/能力/Key 管理操作后调用;跨实例走 pub/sub)。 func (c *Catalog) Invalidate() { select { diff --git a/internal/dispatchadapter/maintenance.go b/internal/dispatchadapter/maintenance.go index d4a2fae..2b0441b 100644 --- a/internal/dispatchadapter/maintenance.go +++ b/internal/dispatchadapter/maintenance.go @@ -148,13 +148,13 @@ func runMaintenance(ctx context.Context) { // 覆盖摘要:目录只装载 active × 有启用能力行的渠道,未覆盖渠道的健康分不会落盘 // (探测却正常的渠道长期停在历史分数时,先看这里是否被跳过) - g.Log().Infof(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered) + g.Log().Debugf(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered) if len(skippedDegraded) > 0 { g.Log().Warningf(ctx, "[ChannelHealth] 本轮 %d 个渠道因 Redis 健康读取失败跳过落盘(保留库中旧值): %v", len(skippedDegraded), skippedDegraded) } if len(skippedNoData) > 0 { - g.Log().Infof(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v", + g.Log().Debugf(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v", len(skippedNoData), skippedNoData) } } @@ -258,7 +258,7 @@ func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alph // 分数有实际变化才记 Info;稳态渠道降为 Debug(避免每 5 分钟 × 渠道数的固定噪音)。 // 两种情况都带各模型 succ 明细:排查"健康分不符合预期"时可直接定位是哪个模型拉低了均值。 if math.Abs(healthScore-prevScore) >= 0.05 { - g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s", + g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s", channelID, prevScore, healthScore, succ, modelDetail) } else { g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康快照无变化: %.1f | avg_succ %.4f | 模型: %s", diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index bb2e7f9..b7d7742 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -798,7 +798,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline }, nil } -// GetWalletInfo 获取租户钱包信息(管理后台) +// GetWalletInfo 获取租户钱包信息(管理后台)。 +// balance/frozen_balance 以 Redis 权威值为准(Redis 是资金提交点,调整余额/线下入账后立即可见), +// DB 侧余额是物化器周期覆盖的滞后副本,仅作钱包行存在性校验与 Redis 不可用时的降级展示。 func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) { type walletRow struct { ID int64 `json:"id"` @@ -815,11 +817,19 @@ func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) return nil, common.NewNotFoundError("钱包") } - return &v1.AdminWalletInfoRes{ + res := &v1.AdminWalletInfoRes{ Balance: w.Balance, FrozenBalance: w.FrozenBalance, WarningThreshold: w.WarningThreshold, - }, nil + } + + // 覆盖为 Redis 实时余额(钱包 hash 缺失时 GetWallet 内部触发灾难恢复重建) + if wallet, err := billing.GetWallet(ctx, req.TenantID); err == nil { + res.Balance = wallet.Balance + res.FrozenBalance = wallet.FrozenBalance + } + + return res, nil } // GetWalletTransactions 获取租户钱包交易流水(管理后台) @@ -905,6 +915,9 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet return nil, err } + // 清除钱包静态字段缓存,让 GetWallet 读到新阈值(对齐租户端 UpdateWarningThreshold) + billing.InvalidateWalletStaticCache(ctx, req.TenantID) + // 阈值变更后重置预警标记,使新阈值能触发新的预警 billing.ResetLowBalanceNotified(ctx, req.TenantID) diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index 53ec24d..31ae47a 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -15,10 +15,14 @@ import ( "github.com/google/uuid" v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/dispatchadapter" "github.com/qianfree/team-api/internal/logic/common" ) +// emailTestTemplateCode 测试邮件在 ntf_send_log 中的模板编码,便于在「邮件发送记录」里筛出。 +const emailTestTemplateCode = "_test" + // GetSettingsCategories returns all available setting categories. func (s *sAdmin) GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error) { categories := common.Categories @@ -243,3 +247,124 @@ func (s *sAdmin) TestStorageConfig(ctx context.Context, req *v1.AdminStorageTest res.Message = "对象存储配置正常:测试图片上传、下载校验通过" return res, nil } + +// TestEmailConfig 发送一封测试邮件,验证 SMTP 配置是否可用。 +// +// 与 TestStorageConfig 同构:请求体携带表单中的邮件配置(可能尚未保存),字段为空或为掩码 +// "******" 时回落到已保存的值,从而支持管理员在「未点保存」的状态下直接测试改动。 +// 收件人留空时发送给当前登录管理员的邮箱。 +// +// 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试 +// 三次也不会变好),不该让管理员多等十几秒的退避。 +func (s *sAdmin) TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error) { + cfg := &common.EmailConfig{ + Host: strings.TrimSpace(req.Host), + Port: req.Port, + Username: strings.TrimSpace(req.Username), + Password: req.Password, + From: strings.TrimSpace(req.From), + FromName: strings.TrimSpace(req.FromName), + } + + // 空 / 掩码 → 回落已保存值(邮件配置的唯一来源是 sys_options,不读配置文件) + if cfg.Host == "" { + cfg.Host = common.Config().GetString(ctx, "email_smtp_host") + } + if cfg.Port == 0 { + cfg.Port = common.Config().GetInt(ctx, "email_smtp_port") + } + if cfg.Username == "" { + cfg.Username = common.Config().GetString(ctx, "email_smtp_username") + } + if cfg.Password == "" || cfg.Password == "******" { + cfg.Password = common.Config().GetString(ctx, "email_smtp_password") + } + if cfg.From == "" { + cfg.From = common.Config().GetString(ctx, "email_smtp_from") + } + if cfg.FromName == "" { + cfg.FromName = common.Config().GetString(ctx, "email_smtp_from_name") + } + // 未传 TLS 开关时沿用已保存值(*bool 区分「没传」和「显式关闭」) + if req.UseTLS != nil { + cfg.UseTLS = *req.UseTLS + } else { + cfg.UseTLS = common.Config().GetBool(ctx, "email_smtp_tls") + } + + if cfg.Host == "" || cfg.From == "" { + return nil, common.NewBadRequestError("请先填写 SMTP 服务器和发件人地址") + } + if cfg.Port == 0 { + cfg.Port = 587 + } + // 与 EmailConfigFromOptions 保持一致:未配置发件人名称时回落站点名称 + if cfg.FromName == "" { + cfg.FromName = common.Config().GetString(ctx, "site_name") + } + + recipient, err := s.resolveTestEmailRecipient(ctx, req.To) + if err != nil { + return nil, err + } + + siteName := common.Config().GetString(ctx, "site_name") + now := time.Now().Format("2006-01-02 15:04:05") + body := fmt.Sprintf(` +
+

邮件配置测试

+

这是一封来自 %s 的测试邮件,收到即表示 SMTP 配置可用。

+

发件人:%s
发送时间:%s

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