From b7e71c685f6b1b3357bd5d5bbf3f3e882db4dd58 Mon Sep 17 00:00:00 2001 From: martin <406615373@qq.com> Date: Sun, 26 Jul 2026 11:45:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=E4=B8=80=E8=87=B4=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/billing/api_key_quota.go | 29 +- internal/logic/billing/member_quota.go | 34 +- internal/logic/billing/settlement.go | 31 +- internal/logic/billing/task_billing.go | 1 + internal/logic/billing/wallet.go | 159 +++++--- internal/logic/common/security.go | 34 +- internal/logic/common/security_test.go | 14 + internal/logic/monitor/collector.go | 9 +- internal/logic/monitor/request_tracker.go | 13 +- .../logic/monitor/request_tracker_test.go | 31 ++ internal/logic/relay/health.go | 29 +- internal/logic/relay/provider.go | 94 ++++- internal/logic/task/task.go | 42 +- 代码分析结果.md | 380 ++++++++++++++++++ 14 files changed, 752 insertions(+), 148 deletions(-) create mode 100644 internal/logic/monitor/request_tracker_test.go create mode 100644 代码分析结果.md diff --git a/internal/logic/billing/api_key_quota.go b/internal/logic/billing/api_key_quota.go index 0f36dc8..f80dfb5 100644 --- a/internal/logic/billing/api_key_quota.go +++ b/internal/logic/billing/api_key_quota.go @@ -40,18 +40,29 @@ func CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float } // IncrApiKeyQuotaUsed increments an API key's used quota after settlement. +// The limit check and increment are one statement so concurrent settlements cannot +// both commit updates that push used_quota beyond total_quota. func IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64) { if apiKeyID <= 0 || amount <= 0 { return } - go func() { - bgCtx := context.Background() - _, err := g.DB().Exec(bgCtx, - "UPDATE api_keys SET used_quota = COALESCE(used_quota, 0) + $1, updated_at = $2 WHERE id = $3", - amount, time.Now(), apiKeyID) - if err != nil { - g.Log().Errorf(bgCtx, "api_key_quota: incr db failed apiKey=%d amount=%f: %v", apiKeyID, amount, err) - } - }() + result, err := g.DB().Exec(ctx, + `UPDATE api_keys + SET used_quota = COALESCE(used_quota, 0) + $1, updated_at = $2 + WHERE id = $3 + AND (COALESCE(total_quota, 0) <= 0 OR COALESCE(used_quota, 0) + $1 <= total_quota)`, + amount, time.Now(), apiKeyID) + if err != nil { + g.Log().Errorf(ctx, "api_key_quota: atomic incr failed apiKey=%d amount=%f: %v", apiKeyID, amount, err) + return + } + affected, err := result.RowsAffected() + if err != nil { + g.Log().Errorf(ctx, "api_key_quota: inspect incr result failed apiKey=%d: %v", apiKeyID, err) + return + } + if affected == 0 { + g.Log().Warningf(ctx, "api_key_quota: settlement increment rejected by quota limit apiKey=%d amount=%f", apiKeyID, amount) + } } diff --git a/internal/logic/billing/member_quota.go b/internal/logic/billing/member_quota.go index 5b88d99..9e54908 100644 --- a/internal/logic/billing/member_quota.go +++ b/internal/logic/billing/member_quota.go @@ -63,13 +63,27 @@ func IncrMemberQuotaUsed(ctx context.Context, tenantID, userID int64, amount flo return } - key := memberQuotaRedisKey(tenantID, userID) - _, err := g.Redis().Do(ctx, "HINCRBYFLOAT", key, "quota_used", amount) + result, err := g.DB().Exec(ctx, + `UPDATE tnt_users + SET quota_used = COALESCE(quota_used, 0) + $1, updated_at = $2 + WHERE id = $3 AND tenant_id = $4 + AND (COALESCE(quota_type, 'none') IN ('', 'none') + OR COALESCE(quota_used, 0) + $1 <= COALESCE(quota_limit, 0))`, + amount, time.Now(), userID, tenantID) if err != nil { - g.Log().Warningf(ctx, "member_quota: redis incr failed tenant=%d user=%d: %v", tenantID, userID, err) + g.Log().Errorf(ctx, "member_quota: atomic incr failed tenant=%d user=%d amount=%f: %v", tenantID, userID, amount, err) + return } - - go incrMemberQuotaDB(tenantID, userID, amount) + affected, err := result.RowsAffected() + if err != nil { + g.Log().Errorf(ctx, "member_quota: inspect incr result failed tenant=%d user=%d: %v", tenantID, userID, err) + return + } + if affected == 0 { + g.Log().Warningf(ctx, "member_quota: settlement increment rejected by quota limit tenant=%d user=%d amount=%f", tenantID, userID, amount) + return + } + InvalidateMemberQuotaCache(ctx, tenantID, userID) } // InvalidateMemberQuotaCache removes the Redis cache for a member's quota. @@ -200,13 +214,3 @@ func resetMemberQuota(ctx context.Context, tenantID, userID int64) { } }() } - -func incrMemberQuotaDB(tenantID, userID int64, amount float64) { - bgCtx := context.Background() - _, err := g.DB().Exec(bgCtx, - "UPDATE tnt_users SET quota_used = quota_used + $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4", - amount, time.Now(), userID, tenantID) - if err != nil { - g.Log().Errorf(bgCtx, "member_quota: incr db failed tenant=%d user=%d amount=%f: %v", tenantID, userID, amount, err) - } -} diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 92a0897..6e6c801 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -55,6 +55,7 @@ type SettlementResult struct { // settlementTxParams 结算事务参数:三处结算共用的事务骨架的可变部分。 type settlementTxParams struct { + tenantID int64 walletID int64 // 钱包 ID preDeductAmount float64 // 预扣冻结金额(事务内从 frozen_balance 释放) actualCost float64 // 实际扣款金额(事务内从 balance 扣除) @@ -114,6 +115,17 @@ func executeSettlementTx(ctx context.Context, p settlementTxParams) (int64, erro return nil }) + if err != nil && !errors.Is(err, errAlreadySettled) { + // 事务回滚后 DB 钱包仍保留原冻结。立即按预扣追踪记录中的原始金额释放, + // 避免等待孤儿清理任务期间持续占用租户可用余额。 + releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + for _, requestID := range p.predeductRequestIDs { + if releaseErr := UnfreezePreDeduct(releaseCtx, p.tenantID, requestID); releaseErr != nil { + g.Log().Errorf(ctx, "%s: release prededuct after rollback request=%s: %v", p.logPrefix, requestID, releaseErr) + } + } + } return billingID, err } @@ -199,6 +211,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, // 5. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态) billingID, err := executeSettlementTx(ctx, settlementTxParams{ + tenantID: tenantID, walletID: wallet.ID, preDeductAmount: preDeductAmount, actualCost: actualCost, @@ -317,6 +330,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID // 5. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态) billingID, err := executeSettlementTx(ctx, settlementTxParams{ + tenantID: tenantID, walletID: wallet.ID, preDeductAmount: preDeductAmount, actualCost: actualCost, @@ -420,9 +434,10 @@ func SettleFailed(ctx context.Context, tenantID int64, requestID string, preDedu return nil } - // 解冻预扣金额 - UnfreezePreDeduct(ctx, tenantID, requestID, preDeductAmount) - markPredeductReleased(ctx, requestID) + // 解冻金额由预扣追踪记录决定,不信任调用方传入的估算值。 + if err := UnfreezePreDeduct(ctx, tenantID, requestID); err != nil { + return err + } // 无需额外操作,预扣金额原路退回 return nil @@ -590,13 +605,3 @@ func createBillingRecordWithSnapshot(ctx context.Context, tenantID, userID, apiK } return id, nil } - -// markPredeductReleased 标记预扣追踪记录为已释放 -func markPredeductReleased(ctx context.Context, requestID string) { - _, err := g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_prededuct_tracks SET status = 'released' WHERE request_id = $1 AND status = 'frozen'", - requestID) - if err != nil { - g.Log().Warningf(ctx, "[PRE-DEDUCT] mark released failed: request=%s err=%v", requestID, err) - } -} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 7d4d607..bbdda6d 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -179,6 +179,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI // 补扣产生的 _adjust 冻结)一次性释放,两条追踪记录都应随之置为 settled;否则残留的 _adjust // frozen 追踪会被日对账判为不一致,并被孤儿清理二次释放。 _, err = executeSettlementTx(ctx, settlementTxParams{ + tenantID: tenantID, walletID: wallet.ID, preDeductAmount: InexactFloat64(preDeductAmount), actualCost: InexactFloat64(actualCost), diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index 2f84d1f..ecaaa25 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" @@ -14,6 +15,7 @@ import ( "github.com/qianfree/team-api/internal/dao" lcommon "github.com/qianfree/team-api/internal/logic/common" + do "github.com/qianfree/team-api/internal/model/do" ) const ( @@ -143,7 +145,7 @@ local ttl = tonumber(ARGV[3]) -- 检查是否已预扣(幂等) local exists = redis.call("EXISTS", prededuct_key) if exists == 1 then - return 1 + return 2 end -- 获取钱包信息(整数 micro) @@ -186,12 +188,23 @@ return 1 if code == 0 { return false, gerror.New("insufficient balance") } + if code == 2 { + return true, nil + } - // 同步同步到 DB(确保 DB frozen_balance 在返回前已更新) - preDeductSyncDB(ctx, tenantID, amount) - - // 异步写入预扣追踪记录(用于孤儿清理和 Redis 重建) - go trackPreDeduct(context.Background(), tenantID, amount, requestID, modelName) + // 同步到 DB(确保 DB frozen_balance 在返回前已更新)。任一步失败都不能把 + // 仅存在于 Redis 的冻结状态暴露给后续请求。 + if err = preDeductSyncDB(ctx, tenantID, amount); err != nil { + rollbackRedisPreDeduct(ctx, tenantID, requestID) + return false, err + } + if err = trackPreDeduct(ctx, tenantID, amount, requestID, modelName); err != nil { + if rollbackErr := unfreezeDBAmount(ctx, tenantID, amount); rollbackErr != nil { + g.Log().Errorf(ctx, "pre-deduct rollback after track failure: tenant=%d request=%s: %v", tenantID, requestID, rollbackErr) + } + rollbackRedisPreDeduct(ctx, tenantID, requestID) + return false, err + } return true, nil } @@ -220,83 +233,91 @@ func preDeductDB(ctx context.Context, tenantID int64, amount float64, requestID // 清除缓存 walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - // 异步写入预扣追踪记录 - go trackPreDeduct(context.Background(), tenantID, amount, requestID, "") + // 追踪记录是后续精确解冻的金额来源,必须在成功返回前落库。 + if err = trackPreDeduct(ctx, tenantID, amount, requestID, ""); err != nil { + if rollbackErr := unfreezeDBAmount(ctx, tenantID, amount); rollbackErr != nil { + g.Log().Errorf(ctx, "pre-deduct db rollback after track failure: tenant=%d request=%s: %v", tenantID, requestID, rollbackErr) + } + return false, err + } return true, nil } // preDeductSyncDB sync pre-deduct to DB(同步调用,确保 DB frozen_balance 在返回前已更新) -func preDeductSyncDB(ctx context.Context, tenantID int64, amount float64) { - _, err := g.DB().Exec(ctx, +func preDeductSyncDB(ctx context.Context, tenantID int64, amount float64) error { + result, err := g.DB().Exec(ctx, "UPDATE bil_wallets SET frozen_balance = frozen_balance + ?, updated_at = ? WHERE tenant_id = ?", amount, time.Now(), tenantID) if err != nil { - g.Log().Errorf(ctx, "pre-deduct sync: %v", err) + return gerror.Wrapf(err, "pre-deduct sync db") } + affected, err := result.RowsAffected() + if err != nil { + return gerror.Wrapf(err, "pre-deduct sync db result") + } + if affected == 0 { + return gerror.Newf("pre-deduct sync db: wallet not found for tenant %d", tenantID) + } + return nil } -// unfreezeClampLua 解冻 frozen_balance 并保证不低于 0(对齐 DB 侧 GREATEST(frozen_balance - ?, 0) 下限)。 -// v2:金额为整数 micro,整数运算无浮点漂移。钱包 hash 不存在(TTL 过期)时直接返回, -// 不凭空创建只含 frozen_balance 的残缺 key——冻结状态以 DB 为准,下次 PreDeduct 的 doSyncWalletToRedis 会重建。 -// KEYS[1] = wallet:v2:{tenant_id};ARGV[1] = 解冻金额(整数 micro) -const unfreezeClampLua = ` -local wallet_key = KEYS[1] -local amount = tonumber(ARGV[1]) -if redis.call("EXISTS", wallet_key) == 0 then - return 0 -end -local frozen = tonumber(redis.call("HGET", wallet_key, "frozen_balance") or "0") -local newFrozen = frozen - amount -if newFrozen < 0 then - newFrozen = 0 -end -redis.call("HSET", wallet_key, "frozen_balance", newFrozen) -return 1 -` - // UnfreezePreDeduct 解冻预扣金额(请求失败时调用) -func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string, amount float64) { - if amount <= 0 { - return +func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string) error { + // 调用方不再提供金额。事务内锁定 frozen 追踪行并读取预扣时保存的金额, + // 既避免错误金额解冻,也让重复调用只有一个事务能实际释放冻结。 + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + var track *struct { + Amount decimal.Decimal `json:"amount"` + } + if err := dao.BilPredeductTracks.Ctx(ctx). + Where("tenant_id", tenantID). + Where("request_id", requestID). + Where("status", "frozen"). + Fields("amount"). + LockUpdate(). + Scan(&track); err != nil { + return gerror.Wrapf(err, "load prededuct for unfreeze") + } + if track == nil || track.Amount.LessThanOrEqual(decimal.Zero) { + return nil + } + + if _, err := g.DB().Ctx(ctx).Exec(ctx, + "UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), updated_at = ? WHERE tenant_id = ?", + track.Amount, time.Now(), tenantID); err != nil { + return gerror.Wrapf(err, "unfreeze wallet") + } + if _, err := dao.BilPredeductTracks.Ctx(ctx). + Where("tenant_id", tenantID). + Where("request_id", requestID). + Where("status", "frozen"). + Data(do.BilPredeductTracks{Status: "released"}). + Update(); err != nil { + return gerror.Wrapf(err, "mark prededuct released") + } + return nil + }) + if err != nil { + InvalidateWalletRedis(ctx, tenantID) + return err } - predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID) - walletRedisKey := walletHashKey(tenantID) - activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) - - // 先尝试 Redis 解冻 - _, err := g.Redis().Do(ctx, "DEL", predeductRedisKey) - if err == nil { - // 解冻(带 0 下限保护,整数运算)。HINCRBY 亦无下限:若因重复/多余调用扣减超过已冻结额, - // 会把 frozen_balance 打成负数。改用 Lua 读-clamp-写,与 DB 侧 - // GREATEST(frozen_balance - ?, 0) 保持一致;钱包 hash 不存在时不凭空创建(交由 DB 兜底)。 - g.Redis().Do(ctx, "EVAL", unfreezeClampLua, 1, walletRedisKey, ToMicro(NewFromFloat(amount))) - g.Redis().Do(ctx, "SREM", activeSetKey, requestID) - go unfreezeSyncDB(tenantID, amount) - return - } - - // Redis 失败,直接 DB 解冻 - unfreezeDB(ctx, tenantID, amount) + walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) + InvalidateWalletRedis(ctx, tenantID) + CleanupPreDeduct(ctx, tenantID, requestID) + return nil } -func unfreezeSyncDB(tenantID int64, amount float64) { - bgCtx := context.Background() - unfreezeDB(bgCtx, tenantID, amount) -} - -func unfreezeDB(ctx context.Context, tenantID int64, amount float64) { - // A9:单条原子更新即可,无需先 SELECT 再 UPDATE。 - // GREATEST(frozen_balance - ?, 0) 保证不会扣成负数;WHERE tenant_id 直接定位钱包行。 +func unfreezeDBAmount(ctx context.Context, tenantID int64, amount float64) error { _, err := g.DB().Exec(ctx, "UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), updated_at = ? WHERE tenant_id = ?", amount, time.Now(), tenantID) if err != nil { - g.Log().Errorf(ctx, "unfreeze db: tenant=%d amount=%.6f: %v", tenantID, amount, err) + return gerror.Wrapf(err, "unfreeze db: tenant=%d amount=%.6f", tenantID, amount) } - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) + return nil } // GetPreDeductAmount 获取预扣金额 @@ -378,6 +399,15 @@ func InvalidateWalletRedis(ctx context.Context, tenantID int64) { g.Redis().Do(ctx, "DEL", walletHashKey(tenantID)) } +func rollbackRedisPreDeduct(ctx context.Context, tenantID int64, requestID string) { + // DB 同步失败后 DB 仍是权威状态。删除钱包 hash 强制下次从 DB 重建, + // 同时清理本次尚未持久化成功的 Redis 预扣明细。 + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + InvalidateWalletRedis(rollbackCtx, tenantID) + CleanupPreDeduct(rollbackCtx, tenantID, requestID) +} + // InvalidateWallet 清除租户钱包的两级缓存(进程内 walletCache + Redis hash)。 // 供 billing 包外(如充值履约 payment.creditWalletTx)在钱包余额变更后调用: // walletCache 是 billing 包私有变量,跨包无法直接 Delete,仅清 Redis 会导致 @@ -478,16 +508,17 @@ func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) { return items, nil } -// trackPreDeduct 异步写入预扣追踪记录到 DB(用于孤儿清理和 Redis 重建) -func trackPreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) { +// trackPreDeduct 写入预扣追踪记录到 DB(用于精确解冻、孤儿清理和 Redis 重建)。 +func trackPreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) error { _, err := g.DB().Ctx(ctx).Exec(ctx, `INSERT INTO bil_prededuct_tracks (tenant_id, request_id, amount, model_name, status) VALUES ($1, $2, $3, $4, 'frozen') ON CONFLICT (request_id) DO NOTHING`, tenantID, requestID, amount, modelName) if err != nil { - g.Log().Warningf(ctx, "[PRE-DEDUCT] track prededuct failed: request=%s err=%v", requestID, err) + return gerror.Wrapf(err, "track prededuct: request=%s", requestID) } + return nil } // rebuildPredeductFromDB 从 DB 恢复活跃预扣明细到 Redis(Redis 重启后调用) diff --git a/internal/logic/common/security.go b/internal/logic/common/security.go index 3e05e09..95b6931 100644 --- a/internal/logic/common/security.go +++ b/internal/logic/common/security.go @@ -3,6 +3,7 @@ package common import ( "context" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "fmt" @@ -214,14 +215,10 @@ func Enable2FA(ctx context.Context, userType string, userID int64, secret, code, return nil, err } - // Hash backup codes for storage + // 恢复码是高熵、一次性随机值,使用 SHA-256 可避免每次失败尝试触发多轮 bcrypt。 hashedCodes := make([]string, len(plainCodes)) for i, code := range plainCodes { - hash, herr := crypto.HashPassword(code) - if herr != nil { - return nil, gerror.Wrapf(herr, "hash backup code failed") - } - hashedCodes[i] = hash + hashedCodes[i] = hashBackupCode(code) } codesJSON, jerr := json.Marshal(hashedCodes) if jerr != nil { @@ -303,7 +300,7 @@ func Verify2FACode(ctx context.Context, userType string, userID int64, code stri // Try backup codes if backupCodes != nil { for i, hashedCode := range backupCodes { - if crypto.VerifyPassword(code, hashedCode) { + if verifyBackupCode(code, hashedCode) { // Consume this backup code _ = consumeBackupCode(ctx, userType, userID, i, backupCodes) return true, nil @@ -376,11 +373,7 @@ func RegenerateBackupCodes(ctx context.Context, userType string, userID int64, c hashedCodes := make([]string, len(plainCodes)) for i, c := range plainCodes { - hash, herr := crypto.HashPassword(c) - if herr != nil { - return nil, gerror.Wrapf(herr, "hash backup code failed") - } - hashedCodes[i] = hash + hashedCodes[i] = hashBackupCode(c) } codesJSON, jerr := json.Marshal(hashedCodes) if jerr != nil { @@ -483,7 +476,7 @@ func verifyAndConsumeBackupCode(ctx context.Context, userType string, userID int } for i, hashedCode := range backupCodes { - if crypto.VerifyPassword(code, hashedCode) { + if verifyBackupCode(code, hashedCode) { _ = consumeBackupCode(ctx, userType, userID, i, backupCodes) return true, nil } @@ -491,6 +484,21 @@ func verifyAndConsumeBackupCode(ctx context.Context, userType string, userID int return false, nil } +const backupCodeSHA256Prefix = "sha256:" + +func hashBackupCode(code string) string { + return backupCodeSHA256Prefix + sha256Sum(code) +} + +func verifyBackupCode(code, storedHash string) bool { + if strings.HasPrefix(storedHash, backupCodeSHA256Prefix) { + expected := hashBackupCode(code) + return subtle.ConstantTimeCompare([]byte(expected), []byte(storedHash)) == 1 + } + // 兼容升级前已签发的 bcrypt 恢复码;用户重新生成后会自动切换到 SHA-256。 + return crypto.VerifyPassword(code, storedHash) +} + // consumeBackupCode removes a used backup code from the list. func consumeBackupCode(ctx context.Context, userType string, userID int64, index int, codes []string) error { newCodes := make([]string, 0, len(codes)-1) diff --git a/internal/logic/common/security_test.go b/internal/logic/common/security_test.go index a53fe5f..3f12451 100644 --- a/internal/logic/common/security_test.go +++ b/internal/logic/common/security_test.go @@ -249,6 +249,20 @@ func TestDeviceFingerprint_Empty(t *testing.T) { } } +func TestBackupCodeSHA256(t *testing.T) { + const code = "ABCD-EFGH" + hashed := hashBackupCode(code) + if !strings.HasPrefix(hashed, backupCodeSHA256Prefix) { + t.Fatalf("backup code hash %q is missing algorithm prefix", hashed) + } + if !verifyBackupCode(code, hashed) { + t.Fatal("correct backup code did not verify") + } + if verifyBackupCode("wrong-code", hashed) { + t.Fatal("incorrect backup code verified") + } +} + // ─── helpers ──────────────────────────────────────────────────────── func generateTestProvisionalToken(userID int64, userType, role string, tenantID int64) (string, error) { diff --git a/internal/logic/monitor/collector.go b/internal/logic/monitor/collector.go index a76ac3e..6cbfd56 100644 --- a/internal/logic/monitor/collector.go +++ b/internal/logic/monitor/collector.go @@ -133,6 +133,7 @@ func (rb *ringBuffer) History(since time.Time) []SystemMetricsSnapshot { var ( systemBuffer *ringBuffer metricsWriter *common.BatchWriter + networkMu sync.Mutex lastNetStats []psnet.IOCountersStat lastNetTime time.Time startupTime time.Time @@ -279,6 +280,10 @@ func InitCollector(ctx context.Context) { systemBuffer = newRingBuffer(360) // 1 hour at 10s intervals metricsWriter = common.NewBatchWriter("ops_system_metrics", 32, 128) startupTime = time.Now() + networkMu.Lock() + lastNetStats = nil + lastNetTime = time.Time{} + networkMu.Unlock() g.Log().Info(ctx, "monitor collector initialized") } @@ -326,8 +331,9 @@ func CollectSystemMetrics(ctx context.Context) error { // Network (calculate per-second rates) if ioStats, err := psnet.IOCounters(false); err == nil && len(ioStats) > 0 { + networkMu.Lock() now := time.Now() - if lastNetTime.IsZero() { + if lastNetTime.IsZero() || len(lastNetStats) == 0 { snapshot.Network.BytesSentPerSec = 0 snapshot.Network.BytesRecvPerSec = 0 } else { @@ -339,6 +345,7 @@ func CollectSystemMetrics(ctx context.Context) error { } lastNetStats = ioStats lastNetTime = now + networkMu.Unlock() } // Go runtime diff --git a/internal/logic/monitor/request_tracker.go b/internal/logic/monitor/request_tracker.go index 25597ea..06bfeaa 100644 --- a/internal/logic/monitor/request_tracker.go +++ b/internal/logic/monitor/request_tracker.go @@ -107,6 +107,7 @@ type requestTracker struct { bytesIn atomic.Int64 bytesOut atomic.Int64 + bandwidthMu sync.Mutex lastBytesIn int64 lastBytesOut int64 lastBwTime time.Time @@ -272,17 +273,21 @@ func GetRealtimeData() *RealtimeData { } func (t *requestTracker) snapshotBandwidth(now time.Time) BandwidthSnapshot { + t.bandwidthMu.Lock() + defer t.bandwidthMu.Unlock() + currentIn := t.bytesIn.Load() currentOut := t.bytesOut.Load() snap := BandwidthSnapshot{Timestamp: now} + if !t.lastBwTime.IsZero() && !now.After(t.lastBwTime) { + return snap + } if !t.lastBwTime.IsZero() { elapsed := now.Sub(t.lastBwTime).Seconds() - if elapsed > 0 { - snap.BytesInPerSec = float64(currentIn-t.lastBytesIn) / elapsed - snap.BytesOutPerSec = float64(currentOut-t.lastBytesOut) / elapsed - } + snap.BytesInPerSec = float64(currentIn-t.lastBytesIn) / elapsed + snap.BytesOutPerSec = float64(currentOut-t.lastBytesOut) / elapsed } t.lastBytesIn = currentIn diff --git a/internal/logic/monitor/request_tracker_test.go b/internal/logic/monitor/request_tracker_test.go new file mode 100644 index 0000000..cdaa797 --- /dev/null +++ b/internal/logic/monitor/request_tracker_test.go @@ -0,0 +1,31 @@ +package monitor + +import ( + "sync" + "testing" + "time" +) + +func TestSnapshotBandwidthConcurrent(t *testing.T) { + tracker := &requestTracker{} + tracker.bytesIn.Store(1024) + tracker.bytesOut.Store(2048) + + base := time.Now() + var workers sync.WaitGroup + for i := range 100 { + workers.Add(1) + go func(offset int) { + defer workers.Done() + tracker.snapshotBandwidth(base.Add(time.Duration(offset) * time.Millisecond)) + }(i) + } + workers.Wait() + + tracker.bytesIn.Add(100) + tracker.bytesOut.Add(200) + snapshot := tracker.snapshotBandwidth(base.Add(time.Second)) + if snapshot.BytesInPerSec < 0 || snapshot.BytesOutPerSec < 0 { + t.Fatalf("bandwidth rates must not be negative: %+v", snapshot) + } +} diff --git a/internal/logic/relay/health.go b/internal/logic/relay/health.go index 135dcc4..a5741f0 100644 --- a/internal/logic/relay/health.go +++ b/internal/logic/relay/health.go @@ -221,23 +221,12 @@ func checkAutoDisable(ctx context.Context, channelID int64, consecutiveFailures // checkAutoRecover 检查自动禁用的渠道是否应该恢复 func checkAutoRecover(ctx context.Context, channelID int64) { - // 查询渠道是否为自动禁用状态 - type chRow struct { - Status string `json:"status"` - AutoDisabled int `json:"auto_disabled"` - } - var row chRow - err := dao.ChnChannels.Ctx(ctx). - Where("id", channelID). - Fields("status, auto_disabled"). - Scan(&row) - if err != nil || row.Status != "disabled" || row.AutoDisabled != 1 { - return - } - - // 成功请求 → 自动恢复 - _, err = dao.ChnChannels.Ctx(ctx). + // 成功请求触发恢复,但只允许修改仍处于“自动禁用”的行。管理员在并发窗口 + // 手动禁用后 auto_disabled 已清零,条件更新不会把它覆盖成 active。 + result, err := dao.ChnChannels.Ctx(ctx). Where("id", channelID). + Where("status", "disabled"). + Where("auto_disabled", 1). Data(do.ChnChannels{ Status: "active", AutoDisabled: 0, @@ -246,6 +235,14 @@ func checkAutoRecover(ctx context.Context, channelID int64) { g.Log().Errorf(ctx, "[AutoRecover] re-enable channel %d failed: %v", channelID, err) return } + affected, err := result.RowsAffected() + if err != nil { + g.Log().Errorf(ctx, "[AutoRecover] inspect channel %d update result failed: %v", channelID, err) + return + } + if affected == 0 { + return + } g.Log().Infof(ctx, "[AutoRecover] channel %d auto-recovered", channelID) diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 7ecd1f7..0a85bd9 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -13,8 +13,10 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "github.com/google/uuid" "github.com/qianfree/team-api/internal/dao" do "github.com/qianfree/team-api/internal/model/do" + "golang.org/x/sync/singleflight" lcommon "github.com/qianfree/team-api/internal/logic/common" loauth "github.com/qianfree/team-api/internal/logic/common/oauth" @@ -30,6 +32,8 @@ const channelKeyLastUsedInterval = time.Minute var channelKeyUsage = newChannelKeyUsageTracker() +var oauthRefreshGroup singleflight.Group + type channelKeyUsageTracker struct { mu sync.Mutex touched map[int64]time.Time @@ -1094,7 +1098,7 @@ func getChannelKey(ctx context.Context, channelID int64) (string, error) { var oauthData loauth.OAuthKeyData if err := json.Unmarshal([]byte(decrypted), &oauthData); err == nil { if oauthData.ExpiresAt > 0 && time.Now().Unix() > oauthData.ExpiresAt-300 { - refreshed, refreshErr := refreshOAuthKey(ctx, key.ID, &oauthData, encKey) + refreshed, refreshErr := refreshOAuthKey(ctx, key.ID, encKey) if refreshErr != nil { g.Log().Warningf(ctx, "[getChannelKey] OAuth refresh failed for key %d: %v", key.ID, refreshErr) } else if refreshed != "" { @@ -1126,9 +1130,85 @@ func touchChannelKeyLastUsed(ctx context.Context, keyID int64) { } // refreshOAuthKey 刷新 OAuth 令牌并更新数据库 -func refreshOAuthKey(ctx context.Context, keyID int64, oauthData *loauth.OAuthKeyData, encKey []byte) (string, error) { +func refreshOAuthKey(ctx context.Context, keyID int64, encKey []byte) (string, error) { + value, err, _ := oauthRefreshGroup.Do(strconv.FormatInt(keyID, 10), func() (any, error) { + release, lockErr := acquireOAuthRefreshLock(ctx, keyID) + if lockErr != nil { + return "", lockErr + } + defer release() + return refreshOAuthKeyLocked(ctx, keyID, encKey) + }) + if err != nil { + return "", err + } + return value.(string), nil +} + +func acquireOAuthRefreshLock(ctx context.Context, keyID int64) (func(), error) { + lockKey := fmt.Sprintf("oauth:refresh:lock:%d", keyID) + lockValue := uuid.NewString() + timeout := time.NewTimer(15 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer timeout.Stop() + defer ticker.Stop() + + for { + result, err := g.Redis().Do(ctx, "SET", lockKey, lockValue, "NX", "EX", 60) + if err != nil { + // Redis 故障时仍由 singleflight 提供本进程保护,避免刷新功能完全不可用。 + g.Log().Warningf(ctx, "[OAuthRefresh] redis lock unavailable for key %d: %v", keyID, err) + return func() {}, nil + } + if !result.IsNil() { + return func() { + const releaseLua = ` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +end +return 0` + _, _ = g.Redis().Do(context.Background(), "EVAL", releaseLua, 1, lockKey, lockValue) + }, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timeout.C: + return nil, fmt.Errorf("timed out waiting for OAuth refresh lock for key %d", keyID) + case <-ticker.C: + } + } +} + +func refreshOAuthKeyLocked(ctx context.Context, keyID int64, encKey []byte) (string, error) { + var row *struct { + EncryptedKey string `json:"encrypted_key"` + } + if err := dao.ChnChannelKeys.Ctx(ctx). + Where("id", keyID). + Where("status", "active"). + Fields("encrypted_key"). + Scan(&row); err != nil { + return "", err + } + if row == nil { + return "", common.ErrChannelUnavailable + } + + decrypted, err := uc.DecryptString(encKey, row.EncryptedKey) + if err != nil { + return "", err + } + var oauthData loauth.OAuthKeyData + if err = json.Unmarshal([]byte(decrypted), &oauthData); err != nil { + return "", err + } + if oauthData.ExpiresAt <= 0 || time.Now().Unix() <= oauthData.ExpiresAt-300 { + return decrypted, nil + } + var newToken *loauth.OAuthKeyData - var err error switch oauthData.Platform { case "claude": @@ -1182,13 +1262,15 @@ func refreshOAuthKey(ctx context.Context, keyID int64, oauthData *loauth.OAuthKe } expiresAt := gtime.NewFromTimeStamp(newToken.ExpiresAt) - dao.ChnChannelKeys.Ctx(ctx). + _, err = dao.ChnChannelKeys.Ctx(ctx). Where("id", keyID). Data(do.ChnChannelKeys{ EncryptedKey: encrypted, TokenExpiresAt: expiresAt, - }). - Update() + }).Update() + if err != nil { + return "", err + } return string(jsonData), nil } diff --git a/internal/logic/task/task.go b/internal/logic/task/task.go index 3a5d354..5165f2a 100644 --- a/internal/logic/task/task.go +++ b/internal/logic/task/task.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - do "github.com/qianfree/team-api/internal/model/do" + "runtime/debug" "sync" "time" @@ -14,6 +14,7 @@ import ( "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" ) // TaskStatus defines the status of an async task. @@ -25,6 +26,8 @@ const ( StatusSucceeded TaskStatus = "succeeded" StatusFailed TaskStatus = "failed" StatusCancelled TaskStatus = "cancelled" + + pendingTaskWorkerCount = 8 ) // Task defines an async task to be executed. @@ -100,7 +103,14 @@ func CreateTask(ctx context.Context, task *Task) (int64, error) { // ExecuteTask executes a task by ID. // It loads the task from DB, updates status, calls the handler, and records the result. -func ExecuteTask(ctx context.Context, taskID int64) error { +func ExecuteTask(ctx context.Context, taskID int64) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + message := fmt.Sprintf("panic: %v\n%s", recovered, debug.Stack()) + err = failTask(ctx, taskID, message) + } + }() + // Load task var task *struct { ID int64 `json:"id"` @@ -111,7 +121,7 @@ func ExecuteTask(ctx context.Context, taskID int64) error { RetryCount int `json:"retry_count"` } - err := dao.TskTasks.Ctx(ctx). + err = dao.TskTasks.Ctx(ctx). Where("id", taskID). Where("status", StatusPending). Scan(&task) @@ -273,11 +283,29 @@ func RunPendingTasks(ctx context.Context) { return } - for _, task := range tasks { - if err := ExecuteTask(ctx, task.ID); err != nil { - g.Log().Errorf(ctx, "execute task %d: %v", task.ID, err) - } + if len(tasks) == 0 { + return } + + workerCount := min(pendingTaskWorkerCount, len(tasks)) + taskIDs := make(chan int64) + var workers sync.WaitGroup + workers.Add(workerCount) + for range workerCount { + go func() { + defer workers.Done() + for taskID := range taskIDs { + if err := ExecuteTask(ctx, taskID); err != nil { + g.Log().Errorf(ctx, "execute task %d: %v", taskID, err) + } + } + }() + } + for _, task := range tasks { + taskIDs <- task.ID + } + close(taskIDs) + workers.Wait() } // RunTaskAsync executes a task in a goroutine. diff --git a/代码分析结果.md b/代码分析结果.md new file mode 100644 index 0000000..00e382a --- /dev/null +++ b/代码分析结果.md @@ -0,0 +1,380 @@ +# Team-API 代码分析结果 + +> **生成时间**:2026-07-25 +> **分析范围**:约 6.9 万行手写业务代码 +> **已排除**:脚手架生成代码(`controller/` `service/` `model/do/` `model/entity/` `dao/`)、`web/` 前端、`relay/` 顶级代理目录、测试代码 +> **覆盖模块**:`api/` `internal/logic/` `internal/cmd/` `internal/consts/` `internal/handler/` `internal/response/` `internal/middleware/` `internal/utility/` +> **分析方法**:4 个并行子 agent 分模块深度审查 + 人工 spot-check 逐行验证所有 P0 结论 + +--- + +## 📖 使用说明 + +1. 本文档按 **优先级** 分组(P0 > P1 > P2 > P3),每条问题有**唯一编号**(如 `P0-1`、`P1-S2`)。 +2. 每条前的 `- [ ]` 是勾选框,修复并验证后改为 `- [x]`。 +3. 每条包含:**位置**(`文件:行号`)、**问题描述**(含关键代码)、**影响**、**修复建议**、**验证方法**。 +4. 文末有 [进度跟踪总表](#-进度跟踪总表),可全局统计剩余条目。 +5. **重要**:[§ 已核实为误报](#-已核实为误报务必阅读) 一节记录了审查过程中发现并排除的假阳性,修改时**不要**再动这些地方。 + +--- + +## 📊 总览 + +| 模块 | 行数 | 测试文件 | 评分 | 一句话评价 | +|------|------|---------|------|-----------| +| billing(计费核心) | 7081 | 14 ✅ | **7.5** | decimal 工具链扎实、预扣多层防御、结算幂等设计精巧 | +| payment(支付履约) | 1026 | 1 | **6.5** | 履约幂等到位,但汇率快照未持久化 | +| common(基础设施) | 9453 | 4 | **7** | 加密/JWT/限流规范,多处 fail-open 安全降级 | +| middleware | 2649 | 3 | **6.5** | 认证体系完整,但 RBAC 默认放行是隐患 | +| utility | 789 | 1 | **8** | 加密/TOTP/CSV 注入防御教科书级 | +| admin 业务层 | 12703 | 0 ❌ | **7** | 错误处理规范、租户隔离意识强,文件过大 | +| tenant 业务层 | 12577 | 0 ❌ | **7** | 同上 | +| handler/relay + logic/relay | 4136 | 3 | **6.5** | 流式转发/捕获设计优秀,但渠道权重失效 | +| task(异步任务) | 3838 | 2 | **5.5** | 有 P0 级功能未完成(见下) | +| monitor | 2650 | 0 | **7** | 告警状态机设计合理,包级状态有竞态 | +| open(开放平台) | 1360 | 0 | **6** | 单文件 1360 行严重过载 | +| api 接口层 | 7837 | — | **7** | binding 规范,金额字段大面积 float64 | + +**综合评分:7 / 10** + +架构分层、注释质量、踩坑复盘文化(多处 `A8/A9 修复`、`C1 修复` 决策记录)都在工业级水准之上。扣分集中在三类系统性问题:**金额 float64 违规**、**几处功能性 P0 bug**、**超大文件职责过载**。 + +--- + + +## 🟠 P1 — 严重问题(建议本迭代修复) + +### 🔐 P1-Security 安全类 + +#### - [ ] P1-S1 RBAC 未匹配路由默认放行 + +- **位置**:`internal/middleware/rbac.go:332-337` +- **问题描述**: + ```go + perm := matchPermission(r.Method, r.URL.Path) + if perm == "" { + // 默认放行:未配置权限规则的接口,对已认证 admin 放行 + r.Middleware.Next() + return + } + ``` + 注释自承是过渡策略。新增 admin 接口忘配规则 = 默认对所有已认证 admin 开放。 +- **影响**:越权风险取决于开发者纪律而非系统约束,是 RBAC 的根本性缺口。 +- **修复建议**: + 1. 改为默认 403。 + 2. 启动期扫描 `api/admin/v1/` 的路由注解,与 `adminPermissionRules` 做 diff,未覆盖的打 warning(最好接入 CI 检查)。 +- **验证方法**:临时新增一个未配规则的 admin 接口,请求应返回 403。 + +#### - [ ] P1-S2 TOTP / OpenAPI nonce 防重放 fail-open + +- **位置**:`internal/logic/common/security.go:39-42`、`internal/middleware/open_auth.go:145-159` +- **问题描述**:两处都在 Redis 故障时 `return true` 放行(注释写"避免依赖故障导致无法登录")。 +- **影响**:Redis 抖动期间同一 TOTP 码可无限重放(2FA 退化为单因素);HMAC 签名请求可在时间窗内重放。 +- **修复建议**:敏感操作(2FA、支付类 webhook)Redis 不可用时 **fail-closed**(拒绝)+ 触发告警;非敏感操作可放行但记指标。 + +#### - [ ] P1-S3 consumeBackupCode TOCTOU + +- **位置**:`internal/logic/common/security.go:495-518` +- **问题描述**:先 `GetUserTOTPSecret` 读全部 backup codes → 内存删一个 → 整体写回,两步非原子。 +- **影响**:两个并发请求用不同恢复码可同时读到完整列表并各自写回,导致其中一个恢复码被"复活"。 +- **修复建议**:用单条 UPDATE 配合 JSONB 操作(`jsonb_remove` 或过滤数组),或在事务内 `SELECT FOR UPDATE`。 + + +#### - [ ] P1-S5 OAuth 自动注册用户名冲突暴露技术细节 + +- **位置**:`internal/logic/tenant/oauth.go:236-257` +- **问题描述**:用户名冲突重试一次失败后,直接返回原始 DB error(含表名/约束名)。 +- **影响**:违反 CLAUDE.md「message 必须中文,禁止暴露技术细节」。 +- **修复建议**:捕获 `IsDuplicateKeyError` 返回业务错误「登录冲突,请重试」。 + + +#### - [ ] P1-S7 generateState / randomHex 忽略 rand.Read 错误 + +- **位置**:`internal/logic/tenant/oauth.go:461-470` +- **问题描述**:`rand.Read(b)` 的 error 被 `_` 吞。系统熵池枯竭时返回全零 bytes,state 可预测。 +- **修复建议**:检查 error,失败时返回 500。 + +--- + +### 💰 P1-Money 金额精度类(违反 CLAUDE.md 强约束) + +#### - [ ] P1-M1 WalletInfo 全程 float64(billing 包最大精度破口) + +- **位置**:`internal/logic/billing/wallet.go:41-49` +- **问题描述**:`Balance`/`FrozenBalance`/`WarningThreshold` 全是 float64。`GetWallet` 从 DB(NUMERIC/decimal)读出后立即降级为 float64 缓存。entity 已是 decimal,缓存层把它丢了。 +- **影响**:全链路比较/运算在 float64 上,违反「金额必须 decimal」强约束;下游 `CheckBalanceWarning`、`AvailableBalance` 全受影响。 +- **修复建议**:`WalletInfo` 字段改 `decimal.Decimal`,对外暴露 `AvailableBalance()` 返回 decimal,API Res 出口再 `.InexactFloat64()`。 + +#### - [ ] P1-M2 member_quota 用 HINCRBYFLOAT + +- **位置**:`internal/logic/billing/member_quota.go:67` +- **问题描述**:Redis 用 `HINCRBYFLOAT` 累加 `quota_used`,正是规范明文要杜绝的反模式。 +- **修复建议**:与 wallet 一致改整数 micro-USD(`billing.ToMicro`/`FromMicro` + `HINCRBY`),或文档化为"非资金控制线允许 float64"并加注释。 + +#### - [ ] P1-M3 API 入口金额字段大面积 float64 + +- **位置**: + - `api/admin/v1/dashboard.go:260`(AdminWalletAdjustReq.Amount) + - `api/admin/v1/tenant_level.go:14,35,50`(CumulativeRechargeThreshold) + - `api/admin/v1/order_admin.go:25-27` + - `api/admin/v1/promo.go:22,72` + - `api/admin/v1/monitor.go:112,131`(Threshold) + - `api/tenant/v1/order_tenant.go:20,52-54,95,107` + - `api/tenant/v1/api_key.go`(TotalQuota) +- **问题描述**:入口 Req 金额字段直接用 float64 参与运算。 +- **修复建议**:入口字段改 decimal,或入口立即 `billing.NewFromFloat(f)` 转进 decimal 再运算。 + +#### - [ ] P1-M4 promo_code 折扣用 float64 计算 + +- **位置**:`internal/logic/tenant/promo_code.go:133-153` +- **问题描述**:`amount * promo.DiscountValue / 100` 走 float64,且 `validatePromoCodeInternal` 返回 `map[string]any`。 +- **修复建议**:改 decimal 运算;返回值改 `PromoValidationResult struct`。 + +#### - [ ] P1-M5 CheckBalanceWarning 用 float64 比较 + +- **位置**:`internal/logic/billing/alert.go:39,124` +- **问题描述**:`available`、`WarningThreshold` 均 float64,违反「金额比较用 .GreaterThan/.IsZero」。 +- **修复建议**:依赖 P1-M1 的 WalletInfo decimal 化后自然解决。 + +--- + +### ⚡ P1-Concurrency 并发与一致性类 + +#### - [x] P1-C1 preDeductSyncDB 失败不回滚 Redis + +- **位置**:`internal/logic/billing/wallet.go:230-237` +- **问题描述**:Lua 冻结成功后 DB UPDATE 失败**只记日志、不回滚 Redis**。 +- **影响**:Redis `frozen_balance` 已增加但 DB 未变,后续预扣基于错误 Redis 值,租户余额被锁死直到 TTL(600s)或人工干预。 +- **修复建议**:DB UPDATE 失败时 `InvalidateWalletRedis` 强制下次重建,并把 error 上抛。 + +#### - [x] P1-C2 结算事务失败不释放预扣冻结 + +- **位置**:`internal/logic/billing/settlement.go:243-256` +- **问题描述**:事务回滚后 frozen_balance 未释放、balance 未扣,调用方直接 `return err`,既不重试也不 `UnfreezePreDeduct`。 +- **影响**:预扣冻结挂着,等 30 分钟 `CleanExpiredPreDeducts` 才释放,期间租户可用余额被锁。 +- **修复建议**:事务失败(且非幂等冲突)后主动 `UnfreezePreDeduct` 释放冻结。 + +#### - [x] P1-C3 UnfreezePreDeduct 信任调用方传参 + +- **位置**:`internal/logic/billing/wallet.go:259-282` +- **问题描述**:解冻金额由调用方传入,DB 侧 `GREATEST(frozen - ?, 0)` 会"吞掉"超额解冻,掩盖 bug。 +- **修复建议**:从 `prededuct:v2:{request_id}` hash 内读出冻结时的 amount(已存),不信任传参。 + +#### - [x] P1-C4 配额检查-递增非原子 + +- **位置**:`internal/logic/billing/api_key_quota.go:15-40`、`member_quota.go:35-58` +- **问题描述**:「读 used + 比较 preDeduct」与「结算后异步 Incr」分离。两个并发请求都通过检查 → 都结算 → 实际超限。 +- **修复建议**:检查时同步原子预占(参考钱包预扣的 Lua),或在 Incr 时再次校验。 + +#### - [x] P1-C5 ExecuteTask 无 panic recover + +- **位置**:`internal/logic/task/task.go:103-192` +- **问题描述**:`RunPendingTasks` 循环里调 `ExecuteTask` 没 recover。 +- **影响**:一个任务 panic 会中断整个 cron 批次,后续任务全部丢失。 +- **修复建议**:`ExecuteTask` 内部 `defer recover` 并把任务标为 failed。 + +#### - [x] P1-C6 monitor 包级共享状态无锁(data race) + +- **位置**:`internal/logic/monitor/request_tracker.go:274-293`、`collector.go:328-342` +- **问题描述**:`lastBytesIn`/`lastBytesOut`/`lastBwTime`/`lastNetStats` 等包级字段无同步。`lastNetStats` 未首次赋值时 `lastNetStats[0]` 会 panic。 +- **修复建议**:用 mutex 包裹或 `atomic.Pointer` 存上一次快照;确保首帧保护。 + +#### - [x] P1-C7 checkAutoRecover TOCTOU + +- **位置**:`internal/logic/relay/health.go:223-258` +- **问题描述**:先 `Scan` 检查 `AutoDisabled == 1` 再无条件 `Update`,没有 `Where auto_disabled = 1` 谓词。 +- **影响**:管理员手动禁用后仍可能被自动恢复逻辑翻成 active。 +- **修复建议**:带谓词的条件更新 + 检查 RowsAffected。 + +#### - [x] P1-C8 refreshOAuthKey 无并发保护 + +- **位置**:`internal/logic/relay/provider.go:1106-1112` +- **问题描述**:两个并发请求同时发现 token 过期 → 同时 refresh → 同时 UPDATE。OAuth refresh token 通常一次性,第二次会失败。 +- **修复建议**:`SELECT FOR UPDATE` 或 Redis 分布式锁串行化单 key 的刷新。 + +#### - [x] P1-C9 2FA backup code 遍历 bcrypt(DoS 风险) + +- **位置**:`internal/logic/common/security.go:303-315` +- **问题描述**:每次 2FA 失败遍历最多 8 个 bcrypt(cost=12),单次约 250ms × 8 = 2s。 +- **修复建议**:backup codes 改 SHA-256 或 Argon2id 低参数(一次性码、低价值,无需 bcrypt)。 + +#### - [x] P1-C10 RunPendingTasks 串行执行 + +- **位置**:`internal/logic/task/task.go:276-280` +- **问题描述**:for 循环顺序执行所有 pending 任务,单个长任务阻塞后续全部。 +- **修复建议**:有界 goroutine 池并发执行,或按 handler 类型分批。 + +--- + +### 🌐 P1-Resource 资源/SSRF 类 + +#### - [ ] P1-R1 rehostFromURL SSRF 风险 + +- **位置**:`internal/logic/task/sync_image_worker.go:756-782` +- **问题描述**:直接 GET 上游返回的 URL 下载图片,无内网地址过滤。 +- **影响**:恶意/被劫持渠道返回 `http://169.254.169.254/latest/meta-data/` 即可让 worker 拉取内网元数据。 +- **修复建议**:过滤 link-local(169.254.0.0/16)、私有 IP 段(10/172.16/192.168)、loopback;解析 DNS 后再校验。 + +#### - [ ] P1-R2 payment LockOrder 引用计数锁竞态 + +- **位置**:`internal/logic/payment/callback.go:30-59` +- **问题描述**:`sync.Map` + 手工引用计数,`UnlockOrder` 中 `rcm.mu.Unlock()` 之后才 `refCount--`,存在归零后误 Delete 的窗口。 +- **修复建议**:用 sharded mutex 或 singleflight 替代手写引用计数;或直接依赖 DB 条件更新删掉进程内锁。 + + +#### - [ ] P1-R4 OAuth HTTP 客户端无超时 + +- **位置**:`internal/logic/tenant/oauth.go:516,556,624` +- **问题描述**:用 `http.DefaultClient`(无超时),上游慢响应会挂死 goroutine。 +- **修复建议**:`&http.Client{Timeout: 10 * time.Second}`。 + +#### - [ ] P1-R5 dispatchAlertNotifications goroutine 无上限 + +- **位置**:`internal/logic/monitor/alert_engine.go:114` + `alert_notify.go:142` +- **问题描述**:ctx 是 `gctx.New()`(无超时),告警风暴时可能产生数百个挂起 goroutine。 +- **修复建议**:给 ctx 加 timeout 或限制并发数。 + +--- + +### 🔄 P1-Transaction 事务边界类 + +#### - [ ] P1-T1 SetTenantGroups 删-插无事务 + +- **位置**:`internal/logic/admin/model_group.go:323-349` +- **问题描述**:先 `Delete()` 旧关联,再 `Batch().Insert()` 新关联,两步不在同一事务。 +- **影响**:插入失败(DB 抖动/唯一键冲突)会清空租户模型分组,影响下游定价。 +- **修复建议**:包 `g.DB().Transaction`,参照项目正确写法 `tenant/member_model_scope.go:88`。 + +#### - [ ] P1-T2 ApiKeyUpdate 主表与 scope 更新分离 + +- **位置**:`internal/logic/tenant/api_key.go:457-463` +- **问题描述**:Key 主表更新与 `updateApiKeyModelScopes` 两步分离,前者成功后者失败留中间态。 +- **修复建议**:外层事务包住两步。 + +#### - [ ] P1-T3 InvalidateWallet 在事务提交前调用 + +- **位置**:`internal/logic/payment/fulfill.go:207` +- **问题描述**:事务闭包内调 `billing.InvalidateWallet`(Redis DEL),但事务尚未提交。并发 `GetWallet` 可能从 DB 读到旧值回写 Redis。 +- **修复建议**:把 `InvalidateWallet` 移到事务外(`FulfillOrder` 返回后调用)。 + +#### - [ ] P1-T4 handleDeletionRequest 跨表无事务 + +- **位置**:`internal/logic/task/data_tasks.go:111-155` +- **问题描述**:跨 `tnt_users`、`api_keys`、`aud_sensitive_access_logs`、`tnt_tenants` 4 张表删除/更新无事务。 +- **影响**:中途失败留下部分应用的中间态(如 API Key 已禁用但租户未标记 terminated)。 +- **修复建议**:`g.DB().Transaction` 包裹。 + +#### - [ ] P1-T5 subscribePlanTx 先取消当前订阅无并发保护 + +- **位置**:`internal/logic/payment/fulfill.go:120-130` +- **问题描述**:把当前 active 订阅置 expired 再 INSERT 新的,两个并发履约可能都读到 active → 两条 active 订阅。 +- **修复建议**:INSERT 后用唯一约束(tenant_id + status='active')防重,或事务内 advisory lock。 + +--- + +## 🟡 P2 — 一般问题(可维护性 / 性能) + +### - [ ] P2-3 N+1 查询 + +- **位置**:`internal/logic/admin/model_import_export.go:79-126` +- **问题描述**:ExportModelsJson 循环每个模型查 `dao.MdlPricing`。 +- **修复建议**:参照 `ListModels`(`model.go:128-136`)用 `WhereIn("model_id", modelIDs)` 一次拉齐。 + +### - [ ] P2-4 错误吞掉(仪表盘静默返回 0) + +- **位置**:`internal/logic/admin/dashboard.go:29-39,46-87` +- **问题描述**:7 个聚合 Count 全部 `_, _ =` 吞错误,仅 Warning 日志。 +- **修复建议**:关键统计返回 error 让上层降级,至少不要 `_` 吞掉。 + +### - [ ] P2-5 map[string]any 返回值(类型不安全) + +- **位置**:`tenant/promo_code.go:81-154`、`admin/notification.go:198-249`、`admin/audit.go` 多处 +- **问题描述**:内部函数返回 map,调用方靠 `result["x"].(int64)` 类型断言。 +- **修复建议**:改 struct。 + +### - [ ] P2-6 CleanExpiredPreDeducts 无分布式锁 + +- **位置**:`internal/logic/billing/reconciliation.go:167-231` +- **问题描述**:多实例部署时 N 个实例同时处理同一批 expired tracks,钱包 UPDATE 无 status 守卫。 +- **修复建议**:钱包 UPDATE 加 `AND EXISTS (...)` 谓词,或 Redis 分布式锁包裹。 + +### - [ ] P2-7 trackPreDeduct 异步写失败无补偿 + +- **位置**:`internal/logic/billing/wallet.go:482-491` +- **问题描述**:异步 INSERT `bil_prededuct_tracks` 失败仅 Warning,Redis 重启后 `rebuildPredeductFromDB` 无法恢复该预扣。 +- **修复建议**:失败重试或写失败队列。 + +### - [ ] P2-8 ProcessCallback 金额校验容差用 float64 + +- **位置**:`internal/logic/payment/callback.go:108-119` +- **问题描述**:容差 0.01 CNY 用 float64 减法。 +- **修复建议**:用 decimal 比较;容差按比例或更小绝对值。 + +### - [ ] P2-9 GetEnabledChannels 无缓存 + +- **位置**:`internal/logic/payment/config.go:114-159` +- **问题描述**:每次调用都查 `sys_options`。 +- **修复建议**:加 60s 内存缓存。 + +### - [ ] P2-10 tenant_auth OAuth callback 路径前缀过宽 + +- **位置**:`internal/middleware/tenant_auth.go:55-58` +- **问题描述**:任何 `/api/tenant/oauth/{任意串}/callback` 都免认证。 +- **修复建议**:`provider` 限定已知集合(`openai|claude|gemini`)。 + +### - [ ] P2-11 充值汇率快照未持久化(违反 CLAUDE.md) + +- **位置**:`internal/logic/payment/fulfill.go:60-65` +- **问题描述**:CNY→USD 换算后只写入流水描述字符串,未结构化持久化「原始 CNY + 当时汇率 + 入账 USD」。 +- **影响**:汇率配置变更后历史对账无法精确重建。 +- **修复建议**:`bil_transactions` 加 `original_amount` / `exchange_rate` / `converted_amount` 三列。 + +### - [ ] P2-12 evaluateCondition eq 用 == 比较 float64 + +- **位置**:`internal/logic/monitor/alert_engine.go:146` +- **修复建议**:`math.Abs(value-threshold) < epsilon`,或文档化 eq 仅适用整数指标。 + +### - [ ] P2-13 api_metrics 无 tenant 隔离 + +- **位置**:`internal/logic/monitor/api_metrics.go:53-56` 等 +- **问题描述**:所有查询未带 tenant_id。 +- **修复建议**:管理后台用合理(平台视角),但若复用到租户接口会泄露数据,加调用方校验。 + +### - [ ] P2-14 api_key listProjectApiKeys 疑似死代码 + +- **位置**:`internal/logic/tenant/api_key.go:588-657` +- **问题描述**:与 `ApiKeyList` 重复,且 grep 仅定义处命中,未被调用。 +- **修复建议**:确认后删除。 + +--- + +## 🔵 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]`,并更新本表数字。 + +--- + +*文档结束。如需针对某一条给出具体修复 diff,告知条目编号即可。*