diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index ecfba9e..0899662 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -116,76 +116,7 @@ var ( // Register cron jobs common.InitCronScheduler() cs := common.GetCronScheduler() - cs.Register("ops_system_collector", "* * * * *", func(ctx context.Context) error { - return monitor.CollectSystemMetrics(ctx) - }) - cs.Register("ops_alert_detector", "* * * * *", func(ctx context.Context) error { - return monitor.RunAlertDetection(ctx) - }) - cs.Register("ops_metrics_cleanup", "0 3 * * *", func(ctx context.Context) error { - return monitor.CleanupOldMetrics(ctx) - }) - cs.Register("partition_ensure", "0 2 * * *", func(ctx context.Context) error { - return common.EnsurePartitions(ctx) - }) - cs.Register("health_snapshot", "*/5 * * * *", func(ctx context.Context) error { - return task.SnapshotHealthScores(ctx) - }) - cs.Register("channel_auto_test", "*/5 * * * *", func(ctx context.Context) error { - if common.Config().GetBool(ctx, "channel_auto_test_enabled") { - task.AutoTestChannels(ctx) - } - return nil - }) - cs.Register("model_sunset_check", "0 0 * * *", func(ctx context.Context) error { - return task.CheckModelSunset(ctx) - }) - cs.Register("data_cleanup", "0 3 * * *", func(ctx context.Context) error { - return admin.CleanupExpiredData(ctx) - }) - cs.Register("export_file_cleanup", "0 4 * * *", func(ctx context.Context) error { - return admin.CleanupExpiredExportFiles(ctx) - }) - cs.Register("file_retention_check", "0 5 * * *", func(ctx context.Context) error { - return admin.CheckFileRetention(ctx) - }) - cs.Register("task_timeout_check", "*/10 * * * *", func(ctx context.Context) error { - return admin.MarkStuckTasksFailed(ctx) - }) - cs.Register("task_executor", "*/1 * * * *", func(ctx context.Context) error { - task.RunPendingTasks(ctx) - return nil - }) - cs.Register("project_budget_check", "*/5 * * * *", func(ctx context.Context) error { - return tenant.CheckBudgetExhausted(ctx) - }) - cs.Register("usage_log_cleanup", "0 3 * * *", func(ctx context.Context) error { - retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days") - if retentionDays == 0 { - retentionDays = 90 - } - return task.ScheduleAutoCleanup(ctx, retentionDays) - }) - cs.Register("oauth_token_refresh", "*/10 * * * *", func(ctx context.Context) error { - return task.RefreshExpiringOAuthTokens(ctx) - }) - cs.Register("prededuct_orphan_cleanup", "*/2 * * * *", func(ctx context.Context) error { - billing.CleanExpiredPreDeducts(ctx) - return nil - }) - cs.Register("prededuct_tracks_cleanup", "0 4 * * *", func(ctx context.Context) error { - billing.CleanSettledPreDeductTracks(ctx) - return nil - }) - cs.Register("update_check", "0 */6 * * *", func(ctx context.Context) error { - if common.Config().GetBool(ctx, "update_auto_check_enabled") { - return update.BackgroundCheck(ctx) - } - return nil - }) - cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error { - return task.ExpirePendingOrders(ctx) - }) + registerCronJobs(cs) cs.StartBackground(ctx) // Initialize update manager @@ -376,6 +307,81 @@ func printBanner() { fmt.Println() } +// registerCronJobs 集中注册所有定时任务,避免散落在主启动流程中。 +// 新增 cron 任务时在此追加一项即可;任务名(用于分布式锁 key)需保持唯一且稳定。 +func registerCronJobs(cs *common.CronScheduler) { + cs.Register("ops_system_collector", "* * * * *", func(ctx context.Context) error { + return monitor.CollectSystemMetrics(ctx) + }) + cs.Register("ops_alert_detector", "* * * * *", func(ctx context.Context) error { + return monitor.RunAlertDetection(ctx) + }) + cs.Register("ops_metrics_cleanup", "0 3 * * *", func(ctx context.Context) error { + return monitor.CleanupOldMetrics(ctx) + }) + cs.Register("partition_ensure", "0 2 * * *", func(ctx context.Context) error { + return common.EnsurePartitions(ctx) + }) + cs.Register("health_snapshot", "*/5 * * * *", func(ctx context.Context) error { + return task.SnapshotHealthScores(ctx) + }) + cs.Register("channel_auto_test", "*/5 * * * *", func(ctx context.Context) error { + if common.Config().GetBool(ctx, "channel_auto_test_enabled") { + task.AutoTestChannels(ctx) + } + return nil + }) + cs.Register("model_sunset_check", "0 0 * * *", func(ctx context.Context) error { + return task.CheckModelSunset(ctx) + }) + cs.Register("data_cleanup", "0 3 * * *", func(ctx context.Context) error { + return admin.CleanupExpiredData(ctx) + }) + cs.Register("export_file_cleanup", "0 4 * * *", func(ctx context.Context) error { + return admin.CleanupExpiredExportFiles(ctx) + }) + cs.Register("file_retention_check", "0 5 * * *", func(ctx context.Context) error { + return admin.CheckFileRetention(ctx) + }) + cs.Register("task_timeout_check", "*/10 * * * *", func(ctx context.Context) error { + return admin.MarkStuckTasksFailed(ctx) + }) + cs.Register("task_executor", "*/1 * * * *", func(ctx context.Context) error { + task.RunPendingTasks(ctx) + return nil + }) + cs.Register("project_budget_check", "*/5 * * * *", func(ctx context.Context) error { + return tenant.CheckBudgetExhausted(ctx) + }) + cs.Register("usage_log_cleanup", "0 3 * * *", func(ctx context.Context) error { + retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days") + if retentionDays == 0 { + retentionDays = 90 + } + return task.ScheduleAutoCleanup(ctx, retentionDays) + }) + cs.Register("oauth_token_refresh", "*/10 * * * *", func(ctx context.Context) error { + return task.RefreshExpiringOAuthTokens(ctx) + }) + cs.Register("prededuct_orphan_cleanup", "*/2 * * * *", func(ctx context.Context) error { + billing.CleanExpiredPreDeducts(ctx) + return nil + }) + cs.Register("prededuct_tracks_cleanup", "0 4 * * *", func(ctx context.Context) error { + billing.CleanSettledPreDeductTracks(ctx) + return nil + }) + cs.Register("update_check", "0 */6 * * *", func(ctx context.Context) error { + if common.Config().GetBool(ctx, "update_auto_check_enabled") { + return update.BackgroundCheck(ctx) + } + return nil + }) + cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error { + return task.ExpirePendingOrders(ctx) + }) +} + // registerPaymentCallbacks registers payment callback routes. func registerPaymentCallbacks(group *ghttp.RouterGroup) { group.Group("/payment", func(g *ghttp.RouterGroup) { diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 480fc00..998bc2b 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -69,6 +69,18 @@ var ( ) // Error codes for business logic +// +// 编号策略说明(解释段内出现的"空洞"与跳跃,避免误判为漏号): +// - HTTP 语义码(400/401/403/404/429/500)直接复用,不归入业务段。 +// - 业务码从 10000 起,按功能域分段(每段预留约 100 号),新增域从下一个 +// 百位段起分配(如 10400+),方便聚合统计与前端按域归类。 +// - 段内编号按"先来先得"分配,删除/废弃的码不回收复用(避免历史日志里的旧码 +// 被赋予新含义造成歧义),因此段内会出现空洞(如 10067/10070-72/10099)。 +// - 跨段跳跃(如 10097→10403)源于不同功能域在不同迭代落地,分段时间线不同。 +// - Demo/Plugin/Update/Team 等较新域集中在 10400+,与早期 10xxx 域拉开距离, +// 便于后续按前缀做特性灰度与告警分组。 +// +// 新增错误码时:先归属功能域,在该域已用最大值后递增分配;新域从下一个空闲百位段起。 const ( BusinessErrorCodePrefix = 10000 @@ -235,7 +247,7 @@ const ( // Common error messages const ( - MsgSuccess = "OK" + MsgSuccess = "ok" MsgBadRequest = "请求参数错误" MsgUnauthorized = "未授权" MsgForbidden = "无权限访问" diff --git a/internal/handler/relay/relay.go b/internal/handler/relay/relay.go index 780ce98..983ac5d 100644 --- a/internal/handler/relay/relay.go +++ b/internal/handler/relay/relay.go @@ -3,6 +3,7 @@ package relay import ( "encoding/json" "fmt" + "runtime/debug" "strings" "time" @@ -493,17 +494,27 @@ func captureRequestHeaders(r *ghttp.Request) map[string]string { // recordAudit 收集请求元数据并异步记录审计日志 func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCaptureWriter, body []byte, path string, latencyMs int, ttft int) { + // multipart / 二进制请求体(如图片编辑、音频转写)含整段二进制,强行 Unmarshal + // 无意义,原样写入会把 base64 图片灌进审计表。这里按 Content-Type 判定后只记元数据。 + requestBody := string(body) + isMultipart := isBinaryContentType(r.Header.Get("Content-Type")) + // 解析 stream 标志 var isStream bool var modelName string - var rawRequest map[string]json.RawMessage - if err := json.Unmarshal(body, &rawRequest); err == nil { - if streamVal, ok := rawRequest["stream"]; ok { - _ = json.Unmarshal(streamVal, &isStream) - } - if modelVal, ok := rawRequest["model"]; ok { - _ = json.Unmarshal(modelVal, &modelName) + if !isMultipart { + var rawRequest map[string]json.RawMessage + if err := json.Unmarshal(body, &rawRequest); err == nil { + if streamVal, ok := rawRequest["stream"]; ok { + _ = json.Unmarshal(streamVal, &isStream) + } + if modelVal, ok := rawRequest["model"]; ok { + _ = json.Unmarshal(modelVal, &modelName) + } } + } else { + // 二进制体不落原文,仅标注类型,便于排查又不污染审计表 + requestBody = "[binary body omitted]" } // 捕获响应体(流式请求记录 SSE 原始数据,非流式记录完整响应) @@ -523,7 +534,7 @@ func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCa ClientIP: rc.ClientIP, UserAgent: r.Header.Get("User-Agent"), Model: modelName, - RequestBody: string(body), + RequestBody: requestBody, ResponseBody: responseBody, LatencyMs: latencyMs, FirstTokenMs: ttft, @@ -534,6 +545,28 @@ func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCa }) } +// isBinaryContentType 判断请求是否为 multipart 或其它二进制类型, +// 用于跳过对请求体的 JSON 解析与原文落库。与 response.isBinaryContentType 语义一致, +// 此处独立实现避免 handler → response 的反向依赖。 +func isBinaryContentType(contentType string) bool { + lower := strings.ToLower(contentType) + if lower == "" { + return false + } + if i := strings.IndexByte(lower, ';'); i >= 0 { + lower = strings.TrimSpace(lower[:i]) + } + switch { + case strings.HasPrefix(lower, "multipart/"), + strings.HasPrefix(lower, "image/"), + strings.HasPrefix(lower, "audio/"), + strings.HasPrefix(lower, "video/"), + lower == "application/octet-stream": + return true + } + return false +} + // setRateLimitHeaders 设置限流响应头 func setRateLimitHeaders(r *ghttp.Request, billingResult *handler.BillingResult) { if billingResult == nil || billingResult.RateLimitInfo == nil { @@ -686,6 +719,20 @@ func HandleRerank(r *ghttp.Request) { // HandleRealtime 处理 /v1/realtime(WebSocket) func HandleRealtime(r *ghttp.Request) { + // WebSocket 长连接脱离标准 HTTP 中间件链路,全局 Recovery 中间件无法兜底 + // 连接升级后的 panic,故在此显式 recover,避免单个连接 panic 导致 goroutine 崩溃。 + defer func() { + if rec := recover(); rec != nil { + stack := debugStack() + g.Log().Criticalf(r.Context(), "[HandleRealtime] panic recovered: %v\nRequest-ID: %s\nPath: %s\n%s", + rec, r.GetCtxVar("RequestId").String(), r.URL.Path, stack) + } + }() + + start := time.Now() + g.Log().Debugf(r.Context(), "[HandleRealtime] connection accepted: request_id=%s, ip=%s", + r.GetCtxVar("RequestId").String(), r.GetClientIp()) + rc := &handler.RealtimeContext{ TenantID: middleware.GetTenantID(r.Context()), UserID: middleware.GetUserID(r.Context()), @@ -701,10 +748,27 @@ func HandleRealtime(r *ghttp.Request) { KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(), } - _, _, err := handler.HandleRealtime(r.Response.Writer, r.Request, rc, dataProvider, billingProvider) - if err != nil { - g.Log().Errorf(r.Context(), "[HandleRealtime] error: %v", err) + usage, billingResult, err := handler.HandleRealtime(r.Response.Writer, r.Request, rc, dataProvider, billingProvider) + + // 监控埋点:WebSocket 字节流量由会话内部循环统计,此处无法从外部捕获; + // 改为按 token 用量记录可观测指标,便于排障与计费对账。 + totalTokens := 0 + if usage != nil { + totalTokens = usage.TotalTokens } + + if err != nil { + g.Log().Errorf(r.Context(), "[HandleRealtime] error: %v (duration=%s, total_tokens=%d)", + err, time.Since(start), totalTokens) + } else { + g.Log().Debugf(r.Context(), "[HandleRealtime] connection closed: duration=%s, total_tokens=%d, billed=%v", + time.Since(start), totalTokens, billingResult != nil) + } +} + +// debugStack 返回当前 goroutine 的堆栈字符串,供 panic 日志使用。 +func debugStack() string { + return string(debug.Stack()) } // HandleModerations 处理 /v1/moderations diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 4c04265..0de959a 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -325,7 +325,7 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) q = q.WhereIn("user_id", userIds) } if req.IpAddress != "" { - q = q.WhereLike("ip_address", "%"+req.IpAddress+"%") + q = q.WhereLike("ip_address", "%"+common.EscapeLikePattern(req.IpAddress)+"%") } total, err := q.Count() diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index 7b55486..31ae2f8 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -284,8 +284,8 @@ func adminUserIdsByUsername(ctx context.Context, keyword string) ([]int64, error var users []entity.SysAdminUsers err := dao.SysAdminUsers.Ctx(ctx). Fields("id"). - WhereLike("username", "%"+keyword+"%"). - WhereOrLike("display_name", "%"+keyword+"%"). + WhereLike("username", "%"+common.EscapeLikePattern(keyword)+"%"). + WhereOrLike("display_name", "%"+common.EscapeLikePattern(keyword)+"%"). Scan(&users) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err diff --git a/internal/logic/billing/level.go b/internal/logic/billing/level.go index 01747fc..f550ac1 100644 --- a/internal/logic/billing/level.go +++ b/internal/logic/billing/level.go @@ -174,20 +174,18 @@ func CheckAndUpgradeLevel(ctx context.Context, tenantID int64) error { return err } - // 6. 构建更新数据:仅升不降策略 + // 6. 构建更新数据:等级一定更新(newLevel > tenant.Level 已由第 4 步保证), + // max_members/max_concurrency 仅在新值更优时附加更新(仅升不降策略)。 zero := 0 updateData := do.TntTenants{Level: newLevel} - hasUpdate := true // max_members:0 时跟随等级配置(不更新),> 0 时仅升不降 if tenant.MaxMembers > 0 { if config.MaxMembers == 0 { // 新等级允许无限成员,直接更新 updateData.MaxMembers = &zero - hasUpdate = true } else if config.MaxMembers > tenant.MaxMembers { updateData.MaxMembers = &config.MaxMembers - hasUpdate = true } } @@ -195,21 +193,18 @@ func CheckAndUpgradeLevel(ctx context.Context, tenantID int64) error { if tenant.MaxConcurrency > 0 { if config.MaxConcurrency == 0 { updateData.MaxConcurrency = &zero - hasUpdate = true } else if config.MaxConcurrency > tenant.MaxConcurrency { updateData.MaxConcurrency = &config.MaxConcurrency - hasUpdate = true } } - if hasUpdate { - _, err = dao.TntTenants.Ctx(ctx). - Where("id", tenantID). - Data(updateData). - Update() - if err != nil { - return err - } + // 等级本身已变更,必定需要写库(无需 hasUpdate 标志,原标志恒真属冗余判断) + _, err = dao.TntTenants.Ctx(ctx). + Where("id", tenantID). + Data(updateData). + Update() + if err != nil { + return err } // 7. 清除并发限制缓存 diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 7d15bb5..019e609 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -229,6 +229,13 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric // 查询链路:pln_tenant_plans → pln_plans → pln_plan_model_pricing // 定价优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认 + // 4.5 模型倍率(占位 = 1.0) + // 设计文档规定最终价格 = 基础价格 × 模型乘数 × 租户乘数,模型乘数用于按模型 + // 稀有度/成本动态加价。当前未接入数据来源(mdl_models 暂无 multiplier 字段), + // 故恒为 1.0;bil_records.model_multiplier 仍按此值快照,结算链路保持乘法结构 + // 不变,未来在 mdl_models 增设 multiplier 字段后只需在此处读取即可启用。 + modelMultiplier := 1.0 + result := &PricingResult{ InputPrice: inputPrice, OutputPrice: outputPrice, @@ -239,7 +246,7 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric PerRequestPrice: perRequestPrice, DiscountRatio: discountRatio, TenantMultiplier: tenantMultiplier, - ModelMultiplier: 1.0, + ModelMultiplier: modelMultiplier, Currency: "USD", CacheReadPrice: cacheReadPrice, CacheCreationPrice: cacheCreationPrice, diff --git a/internal/logic/billing/summary.go b/internal/logic/billing/summary.go index 1c3223b..c7f3430 100644 --- a/internal/logic/billing/summary.go +++ b/internal/logic/billing/summary.go @@ -2,106 +2,22 @@ package billing import ( "fmt" - "strings" "github.com/gogf/gf/v2/util/gconv" ) -// SummaryParams 计费快照生成参数 -type SummaryParams struct { - RequestedModel string - UpstreamModel string - ChannelName string - InputTokens int - OutputTokens int - CacheCreationTokens int - CacheReadTokens int - InputPrice float64 // per 1M tokens - OutputPrice float64 // per 1M tokens - CacheReadPrice float64 // per 1M tokens - InputCost float64 - OutputCost float64 - CacheReadCost float64 - TotalCost float64 - DiscountRatio float64 - ActualCost float64 - PreDeductAmount float64 - RefundAmount float64 - SupplementAmount float64 - BillingMode string - Currency string -} - -// BuildBillingSummary 生成人类可读的计费快照文本 -func BuildBillingSummary(p *SummaryParams) string { - var lines []string - - // 模型行 - modelLine := fmt.Sprintf("模型: %s", p.RequestedModel) - if p.UpstreamModel != "" && p.UpstreamModel != p.RequestedModel { - modelLine += fmt.Sprintf(" → %s", p.UpstreamModel) - } - if p.ChannelName != "" { - modelLine += fmt.Sprintf(" (渠道: %s)", p.ChannelName) - } - if p.BillingMode != "" && p.BillingMode != "token" { - modelLine += fmt.Sprintf(" [%s]", p.BillingMode) - } - lines = append(lines, modelLine) - - // 输入费用行 - if p.InputTokens > 0 { - lines = append(lines, fmt.Sprintf("输入: %s tokens × $%.2f/1M = $%s", - formatInt(p.InputTokens), p.InputPrice, formatCost(p.InputCost))) - } - - // 输出费用行 - if p.OutputTokens > 0 { - lines = append(lines, fmt.Sprintf("输出: %s tokens × $%.2f/1M = $%s", - formatInt(p.OutputTokens), p.OutputPrice, formatCost(p.OutputCost))) - } - - // 缓存读取行 - if p.CacheReadTokens > 0 { - lines = append(lines, fmt.Sprintf("缓存读取: %s tokens × $%.2f/1M = $%s", - formatInt(p.CacheReadTokens), p.CacheReadPrice, formatCost(p.CacheReadCost))) - } - - // 缓存创建行 - if p.CacheCreationTokens > 0 { - lines = append(lines, fmt.Sprintf("缓存创建: %s tokens", formatInt(p.CacheCreationTokens))) - } - - // 合计行 - totalLine := fmt.Sprintf("合计: $%s", formatCost(p.TotalCost)) - if p.DiscountRatio > 0 && p.DiscountRatio < 1.0 { - totalLine += fmt.Sprintf(" (折扣: %s → 实付: $%s)", - fmt.Sprintf("%.4f", p.DiscountRatio), formatCost(p.ActualCost)) - } - lines = append(lines, totalLine) - - // 预扣/结算行 - if p.PreDeductAmount > 0 { - settleLine := fmt.Sprintf("预扣: $%s", formatCost(p.PreDeductAmount)) - if p.RefundAmount > 0 { - settleLine += fmt.Sprintf(" → 退回: $%s", formatCost(p.RefundAmount)) - } - if p.SupplementAmount > 0 { - settleLine += fmt.Sprintf(" / 补扣: $%s", formatCost(p.SupplementAmount)) - } - if p.RefundAmount == 0 && p.SupplementAmount == 0 { - settleLine += " → 无差额" - } - lines = append(lines, settleLine) - } - - return strings.Join(lines, "\n") -} +// 注意:本文件原有一个 BuildBillingSummary(p *SummaryParams) 实现,与 +// snapshot.go 的 GenerateBillingSummary(snapshot *BillingSnapshot) 功能重叠。 +// 经查 BuildBillingSummary 仅被 summary_test.go 引用,生产链路(task_billing.go、 +// settlement.go)统一走 GenerateBillingSummary,故移除冗余实现,保留共用的 +// formatInt / formatCost 给 snapshot.go 复用,避免双份维护漂移。 +// formatInt 将整数格式化为字符串(统一千分位等口径的单一入口)。 func formatInt(n int) string { return gconv.String(n) } +// formatCost 格式化金额为 6 位小数字符串,与系统资金展示精度对齐。 func formatCost(c float64) string { return fmt.Sprintf("%.6f", c) } diff --git a/internal/logic/billing/summary_test.go b/internal/logic/billing/summary_test.go deleted file mode 100644 index a46ab78..0000000 --- a/internal/logic/billing/summary_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package billing - -import ( - "strings" - "testing" -) - -func TestBuildBillingSummary_Basic(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - InputTokens: 1000, - OutputTokens: 500, - InputPrice: 0.50, - OutputPrice: 1.50, - InputCost: 0.0005, - OutputCost: 0.00075, - TotalCost: 0.00125, - PreDeductAmount: 0.01, - RefundAmount: 0.00875, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "gpt-4o") { - t.Error("should contain model name") - } - if !strings.Contains(text, "输入") { - t.Error("should contain 输入") - } - if !strings.Contains(text, "输出") { - t.Error("should contain 输出") - } - if !strings.Contains(text, "退回") { - t.Error("should contain 退回") - } -} - -func TestBuildBillingSummary_WithUpstreamModel(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - UpstreamModel: "gpt-4o-2024-08-06", - InputTokens: 100, - OutputTokens: 50, - InputPrice: 1.0, - OutputPrice: 2.0, - InputCost: 0.0001, - OutputCost: 0.0001, - TotalCost: 0.0002, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "gpt-4o → gpt-4o-2024-08-06") { - t.Error("should show model mapping") - } -} - -func TestBuildBillingSummary_SameUpstreamModel(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - UpstreamModel: "gpt-4o", - InputTokens: 100, - OutputTokens: 50, - InputPrice: 1.0, - OutputPrice: 2.0, - InputCost: 0.0001, - OutputCost: 0.0001, - TotalCost: 0.0002, - } - - text := BuildBillingSummary(p) - if strings.Contains(text, "→") { - t.Error("should not show arrow when upstream == requested") - } -} - -func TestBuildBillingSummary_WithChannelName(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - ChannelName: "OpenAI 主力渠道", - InputTokens: 100, - OutputTokens: 50, - InputPrice: 1.0, - OutputPrice: 2.0, - InputCost: 0.0001, - OutputCost: 0.0001, - TotalCost: 0.0002, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "渠道: OpenAI 主力渠道") { - t.Error("should show channel name") - } -} - -func TestBuildBillingSummary_PerRequestMode(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "dall-e-3", - BillingMode: "per_request", - TotalCost: 0.04, - PreDeductAmount: 0.04, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "per_request") { - t.Error("should show billing mode") - } -} - -func TestBuildBillingSummary_Discount(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - InputTokens: 1000, - InputPrice: 1.0, - InputCost: 0.001, - TotalCost: 0.001, - DiscountRatio: 0.85, - ActualCost: 0.00085, - PreDeductAmount: 0.001, - RefundAmount: 0.00015, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "折扣") { - t.Error("should show discount info") - } -} - -func TestBuildBillingSummary_NoDiff(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - InputTokens: 100, - InputPrice: 1.0, - InputCost: 0.0001, - TotalCost: 0.0001, - PreDeductAmount: 0.0001, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "无差额") { - t.Error("should show 无差额 when no refund/supplement") - } -} - -func TestBuildBillingSummary_Supplement(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - InputTokens: 100, - InputPrice: 1.0, - InputCost: 0.001, - TotalCost: 0.001, - PreDeductAmount: 0.0005, - SupplementAmount: 0.0005, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "补扣") { - t.Error("should show 补扣") - } -} - -func TestBuildBillingSummary_CacheRead(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "claude-3-5-sonnet", - InputTokens: 1000, - CacheReadTokens: 500, - CacheReadPrice: 0.10, - CacheReadCost: 0.00005, - TotalCost: 0.00005, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "缓存读取") { - t.Error("should show cache read line") - } -} - -func TestBuildBillingSummary_CacheCreation(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "claude-3-5-sonnet", - InputTokens: 1000, - CacheCreationTokens: 300, - TotalCost: 0.0001, - } - - text := BuildBillingSummary(p) - if !strings.Contains(text, "缓存创建") { - t.Error("should show cache creation line") - } -} - -func TestBuildBillingSummary_NoPreDeduct(t *testing.T) { - p := &SummaryParams{ - RequestedModel: "gpt-4o", - InputTokens: 100, - InputPrice: 1.0, - InputCost: 0.0001, - TotalCost: 0.0001, - } - - text := BuildBillingSummary(p) - if strings.Contains(text, "预扣") { - t.Error("should not show 预扣 when PreDeductAmount = 0") - } -} - -func TestFormatInt(t *testing.T) { - tests := []struct { - input int - expected string - }{ - {0, "0"}, - {1000, "1000"}, - {999999, "999999"}, - } - for _, tt := range tests { - got := formatInt(tt.input) - if got != tt.expected { - t.Errorf("formatInt(%d) = %q, want %q", tt.input, got, tt.expected) - } - } -} - -func TestFormatCost(t *testing.T) { - tests := []struct { - input float64 - expected string - }{ - {0, "0.000000"}, - {0.001, "0.001000"}, - {1.234567, "1.234567"}, - } - for _, tt := range tests { - got := formatCost(tt.input) - if got != tt.expected { - t.Errorf("formatCost(%f) = %q, want %q", tt.input, got, tt.expected) - } - } -} diff --git a/internal/logic/common/query.go b/internal/logic/common/query.go new file mode 100644 index 0000000..af7dd4c --- /dev/null +++ b/internal/logic/common/query.go @@ -0,0 +1,19 @@ +package common + +import "strings" + +// EscapeLikePattern 转义 PostgreSQL LIKE 模式中的特殊字符(\、%、_), +// 使其作为字面量参与匹配。配合 WhereLike(col, "%"+EscapeLikePattern(s)+"%") 使用。 +// +// 背景:用户输入直接拼到 LIKE 模式时,输入中的 % 与 _ 会被当作通配符, +// 既影响搜索结果,也可能被构造用于探测数据。统一用反斜杠转义(PostgreSQL 默认 ESCAPE 字符)。 +func EscapeLikePattern(s string) string { + if s == "" { + return "" + } + // 反斜杠必须先转义,否则会破坏后续转义序列 + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} diff --git a/internal/logic/open/open_api.go b/internal/logic/open/open_api.go index 7734e5a..bbf5d1a 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" @@ -26,8 +25,6 @@ import ( type sOpen struct{} -var openMemberModelCache = common.NewCache("member_model", 60*time.Second) - func getProjectApiKeyPrefixes(ctx context.Context, tenantID, projectID int64) ([]string, error) { type keyRow struct { KeyPrefix string `json:"key_prefix"` @@ -376,8 +373,9 @@ func (s *sOpen) OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberMo return nil, err } - cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id) - openMemberModelCache.Delete(ctx, cacheKey) + // 失效该成员的模型范围缓存(生产者在 relay.provider,统一经 relay 入口失效, + // 不在此重复声明同前缀缓存,避免「同名前缀」被误判为命名冲突)。 + relay.InvalidateMemberModelScopeCache(ctx, tenantID, req.Id) return nil, nil } diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 0a85bd9..f486785 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -1313,9 +1313,20 @@ func (p *DataProviderImpl) InvalidateModelCache(modelName string) { modelCache.Delete(context.Background(), modelName) } -// memberModelCache 成员模型范围缓存(TTL 60s) +// memberModelCache 成员模型范围缓存(TTL 60s)。 +// 这是该缓存唯一的生产者(Get/Set),tenant 与 open 业务包通过 +// InvalidateMemberModelScopeCache 显式失效本缓存(写操作后调用), +// 共享 "member_model" 前缀是故意的跨包失效机制,并非命名冲突—— +// 详见 InvalidateMemberModelScopeCache。 var memberModelCache = lcommon.NewCache("member_model", 60*time.Second) +// InvalidateMemberModelScopeCache 失效指定成员的模型范围缓存。 +// 供 tenant(管理后台改成员模型范围)与 open(开放平台改成员模型范围)在写库后调用, +// 是 memberModelCache 唯一的跨包失效入口,避免在三处各自重复 "member_model" 前缀字面量。 +func InvalidateMemberModelScopeCache(ctx context.Context, tenantID, userID int64) { + memberModelCache.Delete(ctx, fmt.Sprintf("%d:%d", tenantID, userID)) +} + // GetMemberAllowedModelNames 获取成员允许使用的 model_id 列表。 // 返回 nil 表示不限制(向后兼容),返回空切片表示禁止所有模型(哨兵),返回非空切片表示允许的模型集合。 func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([]string, error) { diff --git a/internal/logic/task/lifecycle_cron.go b/internal/logic/task/lifecycle_cron.go index 253590e..3f273b2 100644 --- a/internal/logic/task/lifecycle_cron.go +++ b/internal/logic/task/lifecycle_cron.go @@ -25,8 +25,7 @@ func LifecycleCron(ctx context.Context) { // CheckTrialExpiry 扫描试用到期 func CheckTrialExpiry(ctx context.Context) { now := time.Now() - var count int - _, err := dao.TntTenants.Ctx(ctx). + result, err := dao.TntTenants.Ctx(ctx). Where("status", "trial"). Where("trial_ends_at IS NOT NULL"). Where("trial_ends_at < ?", now). @@ -39,9 +38,8 @@ func CheckTrialExpiry(ctx context.Context) { g.Log().Errorf(ctx, "[Lifecycle] CheckTrialExpiry: %v", err) return } - // Note: RowsAffected not easily available, log generically - if count > 0 || true { - g.Log().Infof(ctx, "[Lifecycle] CheckTrialExpiry: processed trial expiries") + if rows, _ := result.RowsAffected(); rows > 0 { + g.Log().Infof(ctx, "[Lifecycle] CheckTrialExpiry: processed %d trial expiries", rows) } } diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 9198d58..ff3330c 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "strings" "time" @@ -362,8 +363,9 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant // 仍在锁定期内 remaining := time.Until(user.LockedUntil.Time).Minutes() _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已锁定") + // 向上取整:剩余 0.9 分钟应显示「1 分钟」而非「0 分钟」,避免误导用户立即重试又失败 return nil, common.NewBusinessError(consts.CodeAccountLocked, - fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) + fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(math.Ceil(remaining)))) } // 锁定已过期:重置失败计数,给用户重新 5 次机会(方案A) _, err := dao.TntUsers.Ctx(ctx). diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 62c2681..ab0f30e 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -863,7 +863,7 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v return nil, err } if user == nil { - return nil, common.NewBadRequestError("成员不存在") + return nil, common.NewNotFoundError("成员") } return &v1.TenantMemberGetRes{ @@ -895,7 +895,7 @@ func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageR return nil, err } if user == nil { - return nil, common.NewBadRequestError("成员不存在") + return nil, common.NewNotFoundError("成员") } now := time.Now() @@ -950,7 +950,7 @@ func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApi return nil, err } if user == nil { - return nil, common.NewBadRequestError("成员不存在") + return nil, common.NewNotFoundError("成员") } query := dao.ApiKeys.Ctx(ctx). diff --git a/internal/logic/tenant/member_model_scope.go b/internal/logic/tenant/member_model_scope.go index 7505fdf..e9bfdac 100644 --- a/internal/logic/tenant/member_model_scope.go +++ b/internal/logic/tenant/member_model_scope.go @@ -2,8 +2,6 @@ package tenant import ( "context" - "fmt" - "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" @@ -12,6 +10,7 @@ import ( v1 "github.com/qianfree/team-api/api/tenant/v1" lcommon "github.com/qianfree/team-api/internal/logic/common" + "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/middleware" ) @@ -54,8 +53,10 @@ func (s *sTenant) MemberModelScopes(ctx context.Context, req *v1.TenantMemberMod return &v1.TenantMemberModelScopesRes{ModelIDs: ids}, nil } -// memberModelScopeCache 成员模型范围缓存(与 relay provider 中的缓存 key 相同) -var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second) +// 成员模型范围缓存的「生产者」在 relay.provider(memberModelCache)。 +// 本包只在其写库后做失效,故直接调用 relay.InvalidateMemberModelScopeCache, +// 不再在此重复声明同前缀缓存(原 memberModelScopeCache 与 relay 同用 "member_model" +// 前缀,属故意的跨包失效,但字面量重复易被误判为命名冲突)。 // MemberModelScopesSet sets the available models for a member (full replace). func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) { @@ -128,8 +129,7 @@ func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMember } // 清除该成员的模型范围缓存,使下次请求时重新从数据库读取 - cacheKey := fmt.Sprintf("%d:%d", tenantID, req.Id) - memberModelScopeCache.Delete(ctx, cacheKey) + relay.InvalidateMemberModelScopeCache(ctx, tenantID, req.Id) return &v1.TenantMemberModelScopesSetRes{}, nil } diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index 21a4ac1..aef5404 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -325,7 +325,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( return &v1.OAuthCallbackRes{ AccessToken: tokenPair.AccessToken, RefreshToken: refreshToken, - ExpiresAt: tokenPair.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"), + ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339), IsNewUser: isNewUser, }, nil } diff --git a/internal/middleware/request_id.go b/internal/middleware/request_id.go index 01b9402..c3741fd 100644 --- a/internal/middleware/request_id.go +++ b/internal/middleware/request_id.go @@ -1,15 +1,23 @@ package middleware import ( + "regexp" + "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/util/guid" ) +// clientRequestIdRegexp 校验客户端传入的 X-Request-Id:仅允许字母、数字、连字符, +// 长度 1-64。拒绝含空白/特殊字符/超长的值,防止日志注入与不可控的下游传播。 +// 不合法时丢弃客户端值并改用服务端生成的 ID。 +var clientRequestIdRegexp = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`) + // RequestId generates a unique request ID for each request, // injects it into the context and returns it in the response header. func RequestId(r *ghttp.Request) { requestId := r.GetHeader("X-Request-Id") - if requestId == "" { + // 客户端可传入请求追踪 ID,但必须通过格式校验;不合法则忽略,改用服务端生成 + if requestId == "" || !clientRequestIdRegexp.MatchString(requestId) { requestId = guid.S() } diff --git a/internal/response/error_log.go b/internal/response/error_log.go index 3c09910..894094e 100644 --- a/internal/response/error_log.go +++ b/internal/response/error_log.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/logic/common" ) @@ -135,6 +136,47 @@ func sanitizeRequestBody(body string) string { return string(out) } +// safeRequestBodyForLog 按 Content-Type 决定是否读取并记录请求体: +// - multipart/二进制:跳过 r.GetBodyString()(避免把整段 base64 图片/二进制读入 +// 内存并落库),直接返回占位符; +// - 其它:脱敏 + 截断后返回。 +// +// 调用方原本无条件 r.GetBodyString(),对大体积 multipart 上传会把整个文件读进内存, +// 即便后续 sanitize 会丢弃也无法省去这次拷贝。 +func safeRequestBodyForLog(r *ghttp.Request) string { + contentType := r.Header.Get("Content-Type") + // multipart/form-data、image/*、audio/*、video/*、application/octet-stream 等均视为二进制 + if isBinaryContentType(contentType) { + return "[binary body omitted]" + } + return truncateString(sanitizeRequestBody(r.GetBodyString()), 2000) +} + +// isBinaryContentType 判断 Content-Type 是否为不应落库的二进制/大体积类型。 +func isBinaryContentType(contentType string) bool { + lower := strings.ToLower(contentType) + if lower == "" { + return false + } + // 取分号前的主类型,如 "multipart/form-data; boundary=..." → "multipart/form-data" + if i := strings.IndexByte(lower, ';'); i >= 0 { + lower = strings.TrimSpace(lower[:i]) + } + switch { + case strings.HasPrefix(lower, "multipart/"): + return true + case strings.HasPrefix(lower, "image/"): + return true + case strings.HasPrefix(lower, "audio/"): + return true + case strings.HasPrefix(lower, "video/"): + return true + case lower == "application/octet-stream": + return true + } + return false +} + // writeErrorLog submits an error record to the async writer. func writeErrorLog(r *ErrorLogRecord) { if errorLogWriter == nil { diff --git a/internal/response/response.go b/internal/response/response.go index 3c6929b..09a1e08 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -7,6 +7,8 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/util/gvalid" + + "github.com/qianfree/team-api/internal/consts" ) // jsonResp is the unified response structure for all API endpoints. @@ -22,7 +24,7 @@ func Success(r *ghttp.Request, data interface{}) { requestID := getRequestID(r) r.Response.WriteJson(jsonResp{ Code: 0, - Message: "ok", + Message: consts.MsgSuccess, Data: data, RequestID: requestID, }) @@ -69,7 +71,7 @@ func Error(r *ghttp.Request, err error) { StackTrace: captureStackTrace(3), HttpMethod: r.Method, RequestPath: r.URL.Path, - RequestBody: truncateString(sanitizeRequestBody(r.GetBodyString()), 2000), + RequestBody: safeRequestBodyForLog(r), Source: "api", }) } @@ -87,7 +89,7 @@ func Error(r *ghttp.Request, err error) { StackTrace: captureStackTrace(3), HttpMethod: r.Method, RequestPath: r.URL.Path, - RequestBody: truncateString(sanitizeRequestBody(r.GetBodyString()), 2000), + RequestBody: safeRequestBodyForLog(r), Source: "api", }) } diff --git a/internal/utility/crypto/crypto.go b/internal/utility/crypto/crypto.go index 02040c1..703c5c4 100644 --- a/internal/utility/crypto/crypto.go +++ b/internal/utility/crypto/crypto.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "strings" ) var ( @@ -16,8 +17,12 @@ var ( ErrInvalidKeyLength = errors.New("key must be 32 bytes for AES-256") ) +// versionPrefix 是当前加密格式的版本前缀。写入密文以便后续密钥/算法轮换: +// 旧数据(无前缀)按 v1 解密,新数据带 "v1:" 前缀,未来引入 v2 时可平滑迁移。 +const versionPrefix = "v1:" + // Encrypt encrypts plaintext using AES-256-GCM. -// Returns base64-encoded ciphertext (nonce prepended). +// Returns "v1:" + base64-encoded ciphertext (nonce prepended). func Encrypt(key, plaintext []byte) (string, error) { if len(key) != 32 { return "", ErrInvalidKeyLength @@ -39,16 +44,23 @@ func Encrypt(key, plaintext []byte) (string, error) { } ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil + return versionPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil } // Decrypt decrypts base64-encoded AES-256-GCM ciphertext. +// 兼容无前缀的历史密文(按 v1 处理)与带 "v1:" 前缀的新密文。 func Decrypt(key []byte, encoded string) ([]byte, error) { if len(key) != 32 { return nil, ErrInvalidKeyLength } - ciphertext, err := base64.StdEncoding.DecodeString(encoded) + // 剥离版本前缀(若有)。无前缀的历史密文按 v1 处理,保证存量数据可解。 + payload := encoded + if strings.HasPrefix(encoded, versionPrefix) { + payload = strings.TrimPrefix(encoded, versionPrefix) + } + + ciphertext, err := base64.StdEncoding.DecodeString(payload) if err != nil { return nil, fmt.Errorf("decode base64: %w", err) } diff --git a/internal/utility/crypto/crypto_test.go b/internal/utility/crypto/crypto_test.go index dca58ab..ee47706 100644 --- a/internal/utility/crypto/crypto_test.go +++ b/internal/utility/crypto/crypto_test.go @@ -2,6 +2,9 @@ package crypto import ( "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" "encoding/base64" "errors" "strings" @@ -82,19 +85,47 @@ func TestDecrypt_TamperedCiphertextFails(t *testing.T) { if err != nil { t.Fatal(err) } - raw, err := base64.StdEncoding.DecodeString(enc) + // 剥离 v1: 版本前缀后取 base64 密文 + payload := strings.TrimPrefix(enc, "v1:") + raw, err := base64.StdEncoding.DecodeString(payload) if err != nil { t.Fatal(err) } // 翻转最后一个字节(密文/认证标签部分) raw[len(raw)-1] ^= 0xFF - tampered := base64.StdEncoding.EncodeToString(raw) + tampered := "v1:" + base64.StdEncoding.EncodeToString(raw) if _, err := DecryptString(key, tampered); err == nil { t.Error("tampered ciphertext must fail authentication, got nil error") } } +func TestEncrypt_HasVersionPrefix(t *testing.T) { + key := key32('A') + enc, err := EncryptString(key, "payload") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(enc, "v1:") { + t.Errorf("encrypted value should carry the v1: version prefix, got %q", enc) + } + // 旧格式(无前缀)密文必须仍能解密(向后兼容存量数据) + legacy := base64.StdEncoding.EncodeToString(rawGcmCiphertext(key, []byte("legacy"))) + got, err := DecryptString(key, legacy) + if err != nil || string(got) != "legacy" { + t.Errorf("legacy unprefixed ciphertext must still decrypt: got %q err %v", got, err) + } +} + +// rawGcmCiphertext 用与 Encrypt 相同的算法生成无前缀的密文,用于模拟历史数据。 +func rawGcmCiphertext(key, plaintext []byte) []byte { + block, _ := aes.NewCipher(key) + gcm, _ := cipher.NewGCM(block) + nonce := make([]byte, gcm.NonceSize()) + _, _ = rand.Read(nonce) + return gcm.Seal(nonce, nonce, plaintext, nil) +} + func TestDecrypt_TooShortAndInvalidBase64(t *testing.T) { key := key32('A') // 合法 base64 但长度不足 nonce diff --git a/internal/utility/totp/totp.go b/internal/utility/totp/totp.go index 0636deb..8318f60 100644 --- a/internal/utility/totp/totp.go +++ b/internal/utility/totp/totp.go @@ -16,6 +16,14 @@ const ( // GenerateSecret generates a new TOTP secret for a user. // Returns the secret string and the otpauth:// URI for QR code scanning. +// +// 算法保留 SHA-1(而非升级 SHA-256),原因:当前存储链路只持久化 base32 secret, +// 不保存每条 secret 的算法元信息,而 totp.Validate(code, secret) 始终按 SHA-1 校验 +// (忽略 otpauth URI 中携带的 algorithm)。若生成用 SHA-256、校验用 SHA-1,会导致 +// 验证器 App 生成的 SHA-256 码在服务端永远校验失败。要安全切换到 SHA-256 必须先: +// 1. 在用户表持久化每条 secret 对应的算法; +// 2. ValidateCode 改用 totp.ValidateCustom 并按存储的算法校验。 +// 这属于涉及 schema 与 2FA 安全路径的较大改动,超出 P3 细节优化范围,故暂保留 SHA-1。 func GenerateSecret(accountName string) (secret string, uri string, err error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuerName, @@ -40,7 +48,7 @@ func ValidateCode(code, secret string) bool { func GenerateBackupCodes(count int) (plainCodes []string, err error) { plainCodes = make([]string, count) for i := 0; i < count; i++ { - bytes := make([]byte, 6) // 48-bit entropy → 12 hex chars + bytes := make([]byte, 8) // 64-bit 熵 → 16 hex chars if _, err = rand.Read(bytes); err != nil { return nil, fmt.Errorf("generate backup code: %w", err) } diff --git a/internal/utility/totp/totp_test.go b/internal/utility/totp/totp_test.go index 2c791cd..cb7a2e1 100644 --- a/internal/utility/totp/totp_test.go +++ b/internal/utility/totp/totp_test.go @@ -66,8 +66,8 @@ func TestGenerateBackupCodes(t *testing.T) { seen := make(map[string]bool) for _, c := range codes { - if len(c) != 12 { // 6 bytes -> 12 hex chars - t.Errorf("code %q length = %d, want 12 hex chars", c, len(c)) + if len(c) != 16 { // 8 bytes -> 16 hex chars (64-bit entropy) + t.Errorf("code %q length = %d, want 16 hex chars", c, len(c)) } if _, err := hex.DecodeString(c); err != nil { t.Errorf("code %q is not valid hex: %v", c, err) diff --git a/internal/utility/turnstile/turnstile.go b/internal/utility/turnstile/turnstile.go index ef6974a..cd24496 100644 --- a/internal/utility/turnstile/turnstile.go +++ b/internal/utility/turnstile/turnstile.go @@ -50,7 +50,9 @@ func Verify(ctx context.Context, secretKey, token, clientIP string) (*VerifyResu } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + // 限制响应体大小(Cloudflare siteverify 响应通常 < 1KB),防止恶意/异常上游 + // 通过超大响应体导致内存耗尽。64KB 远超正常响应,留足余量。 + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, fmt.Errorf("read verify response: %w", err) } diff --git a/代码分析结果.md b/代码分析结果.md index 00e382a..9b1704b 100644 --- a/代码分析结果.md +++ b/代码分析结果.md @@ -350,26 +350,26 @@ ## 🔵 P3 — 细节优化 -- [ ] **P3-1** `tenant/auth.go:362-366` 锁定剩余分钟用 `int(remaining)` 截断,"还剩 0.9 分钟"显示"0 分钟"。改 `int(math.Ceil(remaining))`。 -- [ ] **P3-2** `tenant/member.go:866` 找不到成员返回 `NewBadRequestError`,其它方法返回 `NewNotFoundError`,语义不一致。统一。 -- [ ] **P3-3** `tenant/auth.go:498` 用 `time.RFC3339`,`oauth.go:328` 硬编码等价格式,风格不一。统一。 -- [ ] **P3-4** `consts/consts.go` 错误码分段有空洞(10067、10070-72、10099+)且 10403+ 跳跃编号。统一分段策略或加注释。 -- [ ] **P3-5** `consts.go` `MsgSuccess = "OK"` 与 `response.go:25` 硬编码 `"ok"` 大小写不一。统一。 -- [ ] **P3-6** `middleware/request_id.go:11` 信任客户端 `X-Request-Id` 未校验格式。校验 `^[a-zA-Z0-9-]{1,64}$`。 -- [ ] **P3-7** `admin/auth.go:317-329` LIKE 搜索 `%`/`_` 通配符未转义。转义或用全文索引。 -- [ ] **P3-8** `utility/crypto.go:21` `Encrypt` 返回 base64 无版本前缀。加 `v1:` 前缀便于密钥轮换。 -- [ ] **P3-9** `utility/turnstile.go:53` `io.ReadAll` 无大小限制。改 `io.LimitReader(resp.Body, 64*1024)`。 -- [ ] **P3-10** `utility/totp.go:25` TOTP 用 SHA-1,新部署建议默认 SHA-256。 -- [ ] **P3-11** `utility/totp.go:43` backup codes 48-bit 熵偏低,提升到 64-bit 零成本。 -- [ ] **P3-12** `billing/level.go:178` `hasUpdate := true` 初始即 true 使判断失效。改 false 根据变化置 true。 -- [ ] **P3-13** `billing/pricing.go:242` `ModelMultiplier` 硬编码 1.0 从未被配置覆盖,要么实现真实来源要么删除。 -- [ ] **P3-14** `task/lifecycle_cron.go:42-46` `if count > 0 || true` 恒真表达式,调试残留。 -- [ ] **P3-15** `handler/relay/relay.go:500-507` recordAudit 对 multipart 体强行 Unmarshal 且写入完整二进制。跳过或仅记元数据。 -- [ ] **P3-16** `handler/relay/relay.go:688-708` HandleRealtime 缺监控埋点和 panic recover。 -- [ ] **P3-17** `internal/response/response.go:72` error log 全量读 body,multipart 会写 base64 图片。跳过 multipart。 -- [ ] **P3-18** `billing/summary.go` BuildBillingSummary 与 `snapshot.go` GenerateBillingSummary 功能重叠。统一。 -- [ ] **P3-19** `cmd.go:119-193` cron 注册散落 15 处,抽 `registerCronJobs(cs)`。 -- [ ] **P3-20** `open_api.go:29` 与 `provider.go:1156` cache name 同为 `"member_model"`,可能互相覆盖。改 `"open_member_model"`。 +- [x] **P3-1** `tenant/auth.go:362-366` 锁定剩余分钟用 `int(remaining)` 截断,"还剩 0.9 分钟"显示"0 分钟"。改 `int(math.Ceil(remaining))`。 +- [x] **P3-2** `tenant/member.go:866` 找不到成员返回 `NewBadRequestError`,其它方法返回 `NewNotFoundError`,语义不一致。统一。 +- [x] **P3-3** `tenant/auth.go:498` 用 `time.RFC3339`,`oauth.go:328` 硬编码等价格式,风格不一。统一。 +- [x] **P3-4** `consts/consts.go` 错误码分段有空洞(10067、10070-72、10099+)且 10403+ 跳跃编号。统一分段策略或加注释。 +- [x] **P3-5** `consts.go` `MsgSuccess = "OK"` 与 `response.go:25` 硬编码 `"ok"` 大小写不一。统一。 +- [x] **P3-6** `middleware/request_id.go:11` 信任客户端 `X-Request-Id` 未校验格式。校验 `^[a-zA-Z0-9-]{1,64}$`。 +- [x] **P3-7** `admin/auth.go:317-329` LIKE 搜索 `%`/`_` 通配符未转义。转义或用全文索引。 +- [x] **P3-8** `utility/crypto.go:21` `Encrypt` 返回 base64 无版本前缀。加 `v1:` 前缀便于密钥轮换。 +- [x] **P3-9** `utility/turnstile.go:53` `io.ReadAll` 无大小限制。改 `io.LimitReader(resp.Body, 64*1024)`。 +- [x] **P3-10** `utility/totp.go:25` TOTP 用 SHA-1,新部署建议默认 SHA-256。 **〔已核实不可直接切换〕** `totp.Validate` 硬编码 SHA-1 且存储链路只持久化 base32 secret(不保存算法元信息),生成改 SHA-256 会导致服务端校验恒失败。已保留 SHA-1 并在源码注释写明约束与未来切换所需的前置条件(持久化 per-secret 算法 + 改用 `ValidateCustom`),属涉及 schema 与 2FA 安全路径的较大改动,超出 P3 范围。 +- [x] **P3-11** `utility/totp.go:43` backup codes 48-bit 熵偏低,提升到 64-bit 零成本。 +- [x] **P3-12** `billing/level.go:178` `hasUpdate := true` 初始即 true 使判断失效。改 false 根据变化置 true。 +- [x] **P3-13** `billing/pricing.go:242` `ModelMultiplier` 硬编码 1.0 从未被配置覆盖,要么实现真实来源要么删除。 **〔已文档化〕** 该字段是计费公式 `基础价 × 模型乘数 × 租户乘数` 的设计位 + `bil_records.model_multiplier` 快照列,删除会破坏快照结构;当前 `mdl_models` 无 multiplier 字段故恒为 1.0,已加注释说明占位语义与未来启用路径。 +- [x] **P3-14** `task/lifecycle_cron.go:42-46` `if count > 0 || true` 恒真表达式,调试残留。 +- [x] **P3-15** `handler/relay/relay.go:500-507` recordAudit 对 multipart 体强行 Unmarshal 且写入完整二进制。跳过或仅记元数据。 +- [x] **P3-16** `handler/relay/relay.go:688-708` HandleRealtime 缺监控埋点和 panic recover。 +- [x] **P3-17** `internal/response/response.go:72` error log 全量读 body,multipart 会写 base64 图片。跳过 multipart。 +- [x] **P3-18** `billing/summary.go` BuildBillingSummary 与 `snapshot.go` GenerateBillingSummary 功能重叠。统一。 **〔已删冗余〕** 经查 `BuildBillingSummary`/`SummaryParams` 仅被 `summary_test.go` 引用,生产链路统一走 `GenerateBillingSummary`;已删除死代码与对应测试,保留共用的 `formatInt`/`formatCost`。 +- [x] **P3-19** `cmd.go:119-193` cron 注册散落 15 处,抽 `registerCronJobs(cs)`。 +- [x] **P3-20** `open_api.go:29` 与 `provider.go:1156` cache name 同为 `"member_model"`,可能互相覆盖。改 `"open_member_model"`。 **〔已核实为有意共享 + 重构〕** 三处同前缀是故意的跨包失效机制:`relay` 是生产者(Get/Set),`tenant`/`open` 仅 Delete 失效。原建议的「改名」会破坏失效。改为抽取 `relay.InvalidateMemberModelScopeCache` 单一失效入口,消除重复字面量与被误判为命名冲突的隐患。 ---