fix: 修复并发与一致性问题
This commit is contained in:
1 parent
0f87322f49
commit
b7e71c685f
14 files changed
+752
-148
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 重启后调用)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,告知条目编号即可。*
|
||||
Reference in new issue
Block a user