From 1f07fae1c2cd4950ef2a1aabd02640d75ca4b3e7 Mon Sep 17 00:00:00 2001 From: chengqian Date: Mon, 31 Aug 2026 15:59:22 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix(email):=E4=BF=AE=E5=A4=8D=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/common/email.go | 23 +++++++++++++++++-- internal/logic/common/settings_registry.go | 2 ++ internal/logic/monitor/alert_notify.go | 17 +++++++------- .../src/views/system/settings/EmailTab.vue | 3 +++ 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/internal/logic/common/email.go b/internal/logic/common/email.go index 454d9a3..1a9f7d7 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,6 +25,7 @@ type EmailConfig struct { Username string Password string From string + FromName string // 发件人显示名,收件人邮件列表里看到的名字;为空时只显示邮箱地址 UseTLS bool } @@ -121,7 +124,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 +218,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 +226,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/settings_registry.go b/internal/logic/common/settings_registry.go index 128f1bc..ccd25c1 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/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/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index 18461e7..a47ba5c 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -37,6 +37,9 @@ const values = useFormValues() + + +
启用 TLS 587/465 端口建议开启 From 8c8b63da77413575a702088c214a078c0993722d Mon Sep 17 00:00:00 2001 From: chengqian Date: Mon, 31 Aug 2026 16:21:20 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(email):=E4=BF=AE=E5=A4=8D=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/admin.go | 1 + api/admin/v1/settings.go | 22 +++ docs/reference/goframe-conventions.md | 26 ++++ .../controller/admin/admin_v1_settings.go | 3 + internal/logic/admin/settings.go | 125 ++++++++++++++++++ internal/logic/common/email.go | 60 ++++++--- internal/logic/common/email_test.go | 67 ++++++++++ internal/logic/common/notification.go | 51 ++++--- internal/logic/common/verify_code.go | 6 +- internal/logic/tenant/email.go | 3 +- internal/middleware/rbac.go | 2 + internal/service/admin.go | 18 ++- web/admin/src/views/system/SettingsPage.vue | 47 +++++++ 13 files changed, 390 insertions(+), 41 deletions(-) create mode 100644 internal/logic/common/email_test.go diff --git a/api/admin/admin.go b/api/admin/admin.go index e47a7c4..c360e6a 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) 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/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/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/common/email.go b/internal/logic/common/email.go index 1a9f7d7..52e41bc 100644 --- a/internal/logic/common/email.go +++ b/internal/logic/common/email.go @@ -29,6 +29,9 @@ type EmailConfig struct { UseTLS bool } +// emailMaxAttempts 单封邮件的最大投递尝试次数(含首次),失败间隔 2s / 4s。 +const emailMaxAttempts = 3 + // BasicEmailSender provides email sending capability with: // - SMTP connection // - Template rendering (Go templates) @@ -55,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 @@ -67,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 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/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/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/middleware/rbac.go b/internal/middleware/rbac.go index f1cfe01..32222bf 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"}, diff --git a/internal/service/admin.go b/internal/service/admin.go index 59beb6b..a3f16c4 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) 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 @@ @@ -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)); From 211148a0afa3860f959a0d9f87ee8e77eb9ff727 Mon Sep 17 00:00:00 2001 From: qian Date: Mon, 31 Aug 2026 23:11:10 +0800 Subject: [PATCH 07/10] =?UTF-8?q?improve(workbench):=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/workbench.go | 9 +- internal/dispatchadapter/maintenance.go | 6 +- internal/logic/admin/workbench.go | 34 ++---- internal/logic/admin/workbench_collectors.go | 108 +++++++----------- internal/logic/common/cron.go | 15 +++ internal/logic/common/cron_test.go | 27 +++++ web/admin/src/views/AdminLayout.vue | 11 +- web/admin/src/views/WorkbenchPage.vue | 32 +++++- .../src/views/system/settings/EmailTab.vue | 2 +- 9 files changed, 139 insertions(+), 105 deletions(-) diff --git a/api/admin/v1/workbench.go b/api/admin/v1/workbench.go index 5755530..9b84109 100644 --- a/api/admin/v1/workbench.go +++ b/api/admin/v1/workbench.go @@ -97,13 +97,10 @@ type AdminWorkbenchSummaryRes struct { // AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。 // 与 summary 共用同一份缓存,不会额外压库。 type AdminWorkbenchBadgeReq struct { - g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"菜单红点计数"` + 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 数量"` - Domains map[string]int `json:"domains" dc:"各域待办数,键为域标识"` - // Menus 前端菜单路由名 → 待办数,直接挂角标,前端不需要自己映射域到菜单。 - Menus map[string]int `json:"menus" dc:"菜单路由名 → 待办数"` + Total int `json:"total" dc:"待办总数,仅挂在工作台菜单角标"` + Urgent int `json:"urgent" dc:"P0 数量,控制角标转红"` } 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/workbench.go b/internal/logic/admin/workbench.go index 32ff739..6161299 100644 --- a/internal/logic/admin/workbench.go +++ b/internal/logic/admin/workbench.go @@ -39,11 +39,14 @@ const ( wbOrderUnfulfilledMin = 15 // 已支付超过 15 分钟仍未履约视为卡单 wbOAuthExpiryHours = 24 // OAuth 令牌 24 小时内过期即预警 wbHealthDropPoints = 30 // 健康分 1 小时内跌幅超过 30 分视为骤降 - wbCostSpikeMultiple = 8 // 单笔消费超过近期均值 8 倍视为异常 wbEmailFailThreshold = 5 // 1 小时内邮件失败超过 5 封视为链路异常 wbPlanExpiringDays = 7 // 套餐 7 天内到期 wbTicketSLAHours = 2 // 工单首次响应 SLA wbWalletDriftRatio = 0.02 // Redis↔DB 余额偏差超过 2% 且绝对额可观时告警 + + // wbCronStallFallbackInterval 停摆判定的调度周期兜底值:任务不在注册表中 + // (改名/下线后的遗留行)或 cron 表达式解析失败时按「日任务」保守处理。 + wbCronStallFallbackInterval = 24 * time.Hour ) // wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。 @@ -63,14 +66,6 @@ type wbCollected struct { GeneratedAt string `json:"generated_at"` } -// 域 → 前端菜单路由名,供菜单红点直接挂角标。 -var wbDomainMenus = map[string][]string{ - v1.WorkbenchDomainAvailability: {"AdminChannels"}, - v1.WorkbenchDomainMoney: {"AdminOrders", "AdminTransactions"}, - v1.WorkbenchDomainCustomer: {"AdminTickets", "AdminFeedback"}, - v1.WorkbenchDomainSystem: {"AdminAlertEvents", "AdminErrorLogs", "AdminCronJobs"}, -} - // ============================================================ // 对外接口 // ============================================================ @@ -126,30 +121,21 @@ func (s *sAdmin) GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSu return res, nil } -// GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。 +// GetWorkbenchBadges 工作台菜单角标计数(与 summary 共用缓存,不额外压库)。 +// +// 角标只挂工作台菜单一项:待办的排查线索只存在于工作台的描述文案里, +// 业务菜单里没有对应的定位入口,往各业务菜单挂数字只会带来 +// 「进去了却找不到问题」的困惑(红点引路却无路可走)。 func (s *sAdmin) GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error) { col := s.collectWorkbenchCached(ctx) items := s.filterWorkbenchItems(ctx, col.Items) - res := &v1.AdminWorkbenchBadgeRes{ - Domains: map[string]int{}, - Menus: map[string]int{}, - } + res := &v1.AdminWorkbenchBadgeRes{} for _, it := range items { res.Total++ if it.Severity == v1.WorkbenchSeverityP0 { res.Urgent++ } - res.Domains[it.Domain]++ - } - for domain, menus := range wbDomainMenus { - n := res.Domains[domain] - if n == 0 { - continue - } - for _, m := range menus { - res.Menus[m] += n - } } return res, nil } diff --git a/internal/logic/admin/workbench_collectors.go b/internal/logic/admin/workbench_collectors.go index 187489c..ca7e166 100644 --- a/internal/logic/admin/workbench_collectors.go +++ b/internal/logic/admin/workbench_collectors.go @@ -12,6 +12,7 @@ import ( 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" ) // ============================================================ @@ -235,7 +236,6 @@ func (s *sAdmin) wbCollectMoney(ctx context.Context) []wbItem { items = append(items, s.wbWalletDrift(ctx)...) items = append(items, s.wbFrozenStale(ctx)...) items = append(items, s.wbOrderUnfulfilled(ctx)...) - items = append(items, s.wbCostSpike(ctx)...) items = append(items, s.wbLowBalanceTenants(ctx)...) return items } @@ -383,58 +383,6 @@ func (s *sAdmin) wbOrderUnfulfilled(ctx context.Context) []wbItem { return items } -// wbCostSpike 单笔消费异常。 -// 用途有三:防刷、防客户被恶意调用、防定价配置写错一位(乘数多个 0 的杀伤力最大)。 -// 判据是「相对该租户自身近 7 天均值的倍数」,而非全局绝对值 —— 大客户的正常单笔 -// 对小客户就是天价,全局阈值必然两头不讨好。 -func (s *sAdmin) wbCostSpike(ctx context.Context) []wbItem { - rows, err := g.DB().Ctx(ctx).Query(ctx, - `WITH recent AS ( - SELECT tenant_id, id, total_cost, model_name, created_at - FROM bil_usage_logs - WHERE created_at >= NOW() - INTERVAL '2 hours' AND total_cost > 0 - ), baseline AS ( - SELECT tenant_id, AVG(total_cost) AS avg_cost - FROM bil_usage_logs - WHERE created_at >= NOW() - INTERVAL '7 days' AND total_cost > 0 - GROUP BY tenant_id - HAVING COUNT(*) >= 50 - ) - SELECT r.id, r.tenant_id, r.total_cost, r.model_name, r.created_at, - b.avg_cost, t.name AS tenant_name - FROM recent r - JOIN baseline b ON b.tenant_id = r.tenant_id - LEFT JOIN tnt_tenants t ON t.id = r.tenant_id - WHERE r.total_cost >= b.avg_cost * ? - ORDER BY r.total_cost DESC - LIMIT 5`, wbCostSpikeMultiple) - if err != nil { - g.Log().Warningf(ctx, "workbench: 查询消费尖刺失败: %v", err) - return nil - } - - items := make([]wbItem, 0, len(rows)) - for _, r := range rows { - cost := r["total_cost"].Float64() - avg := r["avg_cost"].Float64() - mult := 0.0 - if avg > 0 { - mult = cost / avg - } - items = append(items, wbNew( - fmt.Sprintf("usage_cost_spike:%d", r["id"].Int64()), - v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "billing:view", - fmt.Sprintf("租户「%s」单笔消费异常", r["tenant_name"].String()), - fmt.Sprintf("模型 %s 单笔 %.6f,为该租户近 7 天均值的 %.1f 倍。"+ - "需确认是正常业务、刷量,还是定价配置写错", - r["model_name"].String(), cost, mult), - "查看用量日志", "AdminUsageLogs", - map[string]string{"tenant_id": r["tenant_id"].String()}, r["created_at"].GTime(), - )) - } - return items -} - // wbLowBalanceTenants 余额低于预警阈值的租户。 // 一个信号两个用途:流失预警 + 催充窗口。聚合成一条,不逐租户刷屏。 func (s *sAdmin) wbLowBalanceTenants(ctx context.Context) []wbItem { @@ -606,38 +554,53 @@ 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 - WHERE last_status = 'failed' - OR last_started_at IS NULL - OR last_started_at <= NOW() - INTERVAL '1 hour' - ORDER BY last_started_at ASC NULLS FIRST - LIMIT 10`) + 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 } - desc := "" - if failed { + var desc string + switch { + case failed: desc = "最近一次执行失败" if e := strings.TrimSpace(r["last_error_message"].String()); e != "" { desc += ":" + truncate(e, 150) } - } else if startedAt == nil { + case startedAt == nil: desc = "自服务启动以来从未执行过,调度器可能未正常注册该任务" - } else { - desc = fmt.Sprintf("已 %s 未执行,任务可能已停摆", humanizeSince(startedAt)) + default: + desc = fmt.Sprintf("正常约 %s 执行一次,已 %s 未执行,任务可能已停摆", + humanizeDuration(interval), humanizeSince(startedAt)) } if isMoneyCriticalJob(name) { desc += "。该任务涉及资金结算,停摆会持续累积差错" @@ -654,6 +617,19 @@ func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem { 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) @@ -922,7 +898,11 @@ func humanizeSince(t *gtime.Time) string { if t == nil { return "未知时长" } - d := time.Since(t.Time) + return humanizeDuration(time.Since(t.Time)) +} + +// humanizeDuration 把时长写成人话(分钟/小时/天)。 +func humanizeDuration(d time.Duration) string { if d < time.Minute { return "不到 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/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 8680230..6250eeb 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -9,7 +9,7 @@ import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue' import { useWatermark } from '@/composables/useWatermark' import { useSiteName } from '@/composables/useSiteName' import { - IconThunderbolt, + IconDesktop, IconDashboard, IconUserGroup, IconApps, @@ -306,7 +306,7 @@ const menuGroups = [ label: '数据看板', icon: IconDashboard, items: [ - { name: 'AdminWorkbench', label: '工作台', icon: IconThunderbolt }, + { name: 'AdminWorkbench', label: '工作台', icon: IconDesktop }, { name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard }, { name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand }, ], @@ -459,15 +459,15 @@ function handleClickOutside(e: MouseEvent) { // 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台, // 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存, // 60s 轮询不会额外压库。 -const workbenchBadges = ref>({}) 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 workbenchBadges.value[name] || 0 + return 0 } async function fetchWorkbenchBadges() { @@ -475,7 +475,6 @@ async function fetchWorkbenchBadges() { const res = await request.get('/admin/workbench/badges') const data = res.data?.data if (!data) return - workbenchBadges.value = data.menus || {} workbenchUrgent.value = data.urgent || 0 workbenchTotal.value = data.total || 0 } catch { diff --git a/web/admin/src/views/WorkbenchPage.vue b/web/admin/src/views/WorkbenchPage.vue index 40c0b61..1430a2d 100644 --- a/web/admin/src/views/WorkbenchPage.vue +++ b/web/admin/src/views/WorkbenchPage.vue @@ -533,6 +533,22 @@ onBeforeUnmount(() => { font-weight: 600; } +/* 卡片固定高度:告警再多也在卡片内部滚动,不无限撑开页面 */ +.wb-flow :deep(.arco-card-body) { + height: 640px; + overflow-y: auto; +} + +/* 细滚动条,贴合整体视觉 */ +.wb-flow :deep(.arco-card-body)::-webkit-scrollbar { + width: 6px; +} + +.wb-flow :deep(.arco-card-body)::-webkit-scrollbar-thumb { + background: var(--color-fill-3); + border-radius: 3px; +} + .wb-list { display: flex; flex-direction: column; @@ -596,8 +612,13 @@ onBeforeUnmount(() => { } /* ===== 空态:清空是正常状态 ===== */ +/* 卡片 body 定高后,空态在固定高度内垂直居中 */ .wb-empty { - padding: 48px 0; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; } @@ -793,6 +814,11 @@ onBeforeUnmount(() => { .wb-body { grid-template-columns: 1fr; } + + /* 单列布局下视口纵向空间更紧张,卡片定高同步收紧 */ + .wb-flow :deep(.arco-card-body) { + height: 560px; + } } @media (max-width: 767px) { @@ -814,5 +840,9 @@ onBeforeUnmount(() => { .wb-item__title { min-width: 100%; } + + .wb-flow :deep(.arco-card-body) { + height: 480px; + } } diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index a47ba5c..ca07ea5 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -38,7 +38,7 @@ const values = useFormValues() - +
启用 TLS From bf5dc7684d63ac5f9140df2b6a46273072334c54 Mon Sep 17 00:00:00 2001 From: qian Date: Mon, 31 Aug 2026 23:11:38 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(log):=E4=BF=AE=E5=A4=8Dtoken=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E9=94=99=E8=AF=AF=EF=BC=8C=E7=BC=93=E5=AD=98=E7=8E=87?= =?UTF-8?q?=E8=B6=85=E8=BF=87100%=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/000019_v0_2_16_version.sql | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 migrations/000019_v0_2_16_version.sql 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 From 15ec726f689425013d22c85cf0526eaa48bfd03d Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 1 Sep 2026 00:11:32 +0800 Subject: [PATCH 09/10] =?UTF-8?q?improve(log):=E4=BC=98=E5=8C=96=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=97=A5=E5=BF=97=E6=8F=AD=E9=9C=B2=EF=BC=8C=E6=96=B9?= =?UTF-8?q?=E4=BE=BF=E6=8E=92=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/task/async_polling.go | 32 ++++--- relay/handler/relay_handler.go | 57 ++++++++++-- relay/handler/stream_diag_test.go | 131 +++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 relay/handler/stream_diag_test.go 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/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index cdccf07..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,8 +716,9 @@ 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 @@ -737,7 +744,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } interruptSettle = settleResult } - recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle) + 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 @@ -785,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) @@ -954,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} } @@ -1029,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, @@ -1045,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 @@ -1058,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, settleResult *common.SettlementResult) { +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, @@ -1071,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 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) + } +} From f0feacd15fd19f8b22e16c18b86379f56e7d902b Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 1 Sep 2026 00:44:20 +0800 Subject: [PATCH 10/10] =?UTF-8?q?improve(ui):=E4=BC=98=E5=8C=96=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AB=AFui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/dashboard/DashboardPage.vue | 5 +- .../views/dashboard/PersonalDashboardPage.vue | 87 +++++++------------ 2 files changed, 31 insertions(+), 61 deletions(-) diff --git a/web/tenant/src/views/dashboard/DashboardPage.vue b/web/tenant/src/views/dashboard/DashboardPage.vue index 856c81f..6104937 100644 --- a/web/tenant/src/views/dashboard/DashboardPage.vue +++ b/web/tenant/src/views/dashboard/DashboardPage.vue @@ -130,8 +130,8 @@ const memberUsageLoading = ref(false) const alertsLoading = ref(false) const healthLoading = ref(false) const budgetLoading = ref(false) -// 管理者看趋势默认看 30 天:经营视角关心的是月度走势,不是最近一周 -const selectedDays = ref(30) +// 消费趋势默认看最近 7 天 +const selectedDays = ref(7) const dashboardData = ref(null) const trendData = ref([]) const modelData = ref([]) @@ -416,7 +416,6 @@ onBeforeUnmount(() => {
-

{{ authStore.tenant?.name || '租户控制台' }}

{{ greeting }},{{ authStore.user?.username || 'Admin' }} 👋

diff --git a/web/tenant/src/views/dashboard/PersonalDashboardPage.vue b/web/tenant/src/views/dashboard/PersonalDashboardPage.vue index dd3c019..059bf8e 100644 --- a/web/tenant/src/views/dashboard/PersonalDashboardPage.vue +++ b/web/tenant/src/views/dashboard/PersonalDashboardPage.vue @@ -239,7 +239,6 @@ onMounted(refreshAll)
-

{{ authStore.tenant?.name || '个人工作台' }}

{{ greeting }},{{ authStore.user?.username || '用户' }}

@@ -438,27 +437,15 @@ onMounted(refreshAll)
-
-
-
-

平均响应

-

{{ formatMs(overviewData.latency.avg_ms) }}

-
-
-

首 Token

-

{{ formatMs(overviewData.latency.avg_first_token_ms) }}

-
+
+
+

平均响应

+

{{ formatMs(overviewData.latency.avg_ms) }}

+
+
+

首 Token

+

{{ formatMs(overviewData.latency.avg_first_token_ms) }}

- -
- 展开延迟分位数 -
-
-

{{ item.label }}

-

{{ item.value }}

-
-
-
@@ -479,11 +466,23 @@ onMounted(refreshAll)
-
-
- -
- 缓存命中率 {{ (overviewData.cache.hit_ratio * 100).toFixed(1) }}% + +
+
+
+

{{ item.label }}

+

{{ item.value }}

+
+
+
+
+ +
+
+
+ {{ (overviewData.cache.hit_ratio * 100).toFixed(1) }}% + 缓存命中率 +

已命中 {{ formatNumber(overviewData.cache.cache_read_tokens) }} Token,约省下 {{ formatCost(overviewData.cache.saved_cost) }}

@@ -913,30 +912,6 @@ onMounted(refreshAll) padding: 0.7rem 0.8rem; } -.latency-details { - margin-top: 0.7rem; -} - -.latency-details > summary { - color: #0d9488; - cursor: pointer; - font-size: 0.75rem; - font-weight: 620; - list-style: none; -} - -.latency-details > summary::-webkit-details-marker { - display: none; -} - -.latency-details > summary::after { - content: ' ▾'; -} - -.latency-details[open] > summary::after { - content: ' ▴'; -} - .mini-progress { height: 0.35rem; overflow: hidden; @@ -951,14 +926,10 @@ onMounted(refreshAll) background: linear-gradient(90deg, #2dd4bf, #0d9488); } -.cache-strip { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - border-radius: 0.8rem; - background: rgba(6, 182, 212, 0.08); - padding: 0.7rem 0.85rem; +/* 缓存命中小卡片:在次级指标带里用青色调区分身份 */ +.quality-metric-cache { + border-color: rgba(6, 182, 212, 0.2); + background: rgba(6, 182, 212, 0.07); } /* ── 失败列表 ── */