fix: 完成 P3 细节优化项

依据《代码分析结果.md》P3 章节,逐条修复 20 项细节问题:

正确性修复:
- P3-1 锁定剩余分钟改用 math.Ceil,避免「0.9 分钟」显示为「0 分钟」
- P3-12 移除 CheckAndUpgradeLevel 中恒真的 hasUpdate 标志
- P3-14 移除 CheckTrialExpiry 中恒真表达式(count > 0 || true)调试残留,改用 RowsAffected
- P3-2 成员不存在统一返回 NewNotFoundError(404),与其它方法语义一致

安全加固:
- P3-6 X-Request-Id 加格式校验 ^[A-Za-z0-9-]{1,64}$,防日志注入
- P3-7 新增公共 EscapeLikePattern,转义 admin LIKE 搜索的 %/_ 通配符
- P3-8 Encrypt 输出加 v1: 版本前缀,Decrypt 兼容无前缀历史密文
- P3-9 turnstile 响应体读取加 64KB 上限,防内存耗尽
- P3-11 backup codes 熵由 48-bit 提升到 64-bit
- P3-15 recordAudit 对 multipart/二进制体跳过 Unmarshal 与原文落库
- P3-17 错误日志按 Content-Type 跳过 multipart/二进制体读取
- P3-16 HandleRealtime 加 panic recover 与连接生命周期日志

可维护性/一致性:
- P3-3 oauth.go 时间格式统一为 time.RFC3339
- P3-5 MsgSuccess 统一为 ok,response.go 改用常量引用
- P3-4 错误码分段策略加注释,说明空洞与跳跃的成因
- P3-13 ModelMultiplier 占位语义加注释(计费公式设计位 + 快照列)
- P3-19 抽取 registerCronJobs,主启动流程清爽

重构:
- P3-18 删除死代码 BuildBillingSummary/SummaryParams(仅测试引用),保留共用 helper
- P3-20 抽取 relay.InvalidateMemberModelScopeCache 单一失效入口,
  消除三处 member_model 前缀字面量重复(原为有意跨包失效,原建议改名会破坏)

经核实保留 + 文档化:
- P3-10 TOTP 暂保留 SHA-1:totp.Validate 硬编码 SHA-1 且存储不保存算法元信息,
  直接切 SHA-256 会导致校验恒失败,需配合 schema 改动,超出 P3 范围
This commit is contained in:
martin committed 2026-07-26 12:16:33 +08:00
1 parent b7e71c685f
commit a6ed67d841
26 files changed
+379 -483

No files matched your search

+76 -70
View File
@@ -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) {
+13 -1
View File
@@ -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 = "无权限访问"
+75 -11
View File
@@ -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
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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
+9 -14
View File
@@ -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. 清除并发限制缓存
+8 -1
View File
@@ -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,
+7 -91
View File
@@ -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)
}
-237
View File
@@ -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)
}
}
}
+19
View File
@@ -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
}
+3 -5
View File
@@ -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
}
+12 -1
View File
@@ -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) {
+3 -5
View File
@@ -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)
}
}
+3 -1
View File
@@ -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).
+3 -3
View File
@@ -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).
+6 -6
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+9 -1
View File
@@ -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()
}
+42
View File
@@ -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 {
+5 -3
View File
@@ -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",
})
}
+15 -3
View File
@@ -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)
}
+33 -2
View File
@@ -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
+9 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+3 -1
View File
@@ -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)
}
+20 -20
View File
@@ -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` 单一失效入口,消除重复字面量与被误判为命名冲突的隐患。
---