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:
1 parent
b7e71c685f
commit
a6ed67d841
26 files changed
+379
-483
No files matched your search
+76
-70
@@ -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) {
|
||||
|
||||
@@ -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 = "无权限访问"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 清除并发限制缓存
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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` 单一失效入口,消除重复字面量与被误判为命名冲突的隐患。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user