Files
zt-closet-backend/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md
2026-09-11 01:41:52 +08:00

8.5 KiB
Raw Permalink Blame History

name, overview, todos
name overview todos
配额流水事务一致性修复 按方案 A 将「配额数值变更」与「写配额流水」合并到同一数据库事务,消除 best-effort 静默丢流水导致的账实不符(配额泄漏)风险,并全面排查同类问题、为其余 best-effort 写操作补齐日志。
id content status
dao-transactional-methods 在 internal/mysqldao/quota.go 新增 QuotaConsumeWithFlow、QuotaRefundWithFlow、QuotaInitWithFlow 三个事务方法,并为 QuotaFlowLog 模型补唯一索引 tag completed
id content status dependencies
service-consume-refund 改造 internal/service/service.go 的 consumeQuota 与 refundQuota 调用新事务方法,删除分离的 writeQuotaFlow 调用及函数 completed
dao-transactional-methods
id content status dependencies
clothes-delete-transaction 将 internal/service/clothes.go DeleteByUUID 的流水写入移入既有事务闭包,消除事务外写流水窗口 completed
dao-transactional-methods
id content status dependencies
ensure-default-quota-transaction 将 internal/service/quota.go ensureDefaultQuota 改用 QuotaInitWithFlow 实现并发安全幂等初始化 completed
dao-transactional-methods
id content status
best-effort-logging 为 airecommend.go、outfit.go、closet.go、notification.go、service.go recordDailyUsage、user.go 等保留的 best-effort 写操作补失败日志 completed
id content status dependencies
build-verify 执行 go build、go vet 与 lint 检查,回归核对所有 consumeQuota/refundQuota 调用点返回语义 completed
service-consume-refund
clothes-delete-transaction
ensure-default-quota-transaction
best-effort-logging

产品概述

修复配额流水与配额数值不一致导致的「配额泄漏」问题。核心思路(方案 A):将「扣减/回退配额」与「写配额流水」合并进同一数据库事务,保证数值变更与流水记录强一致;同时全面排查所有同类位置并补齐失败日志。

核心功能

  • 事务化扣减(consume):扣减 used 与写流水在同一事务,流水写失败则整体回滚,返回数据库错误,杜绝「额度已扣但查不到流水」。
  • 事务化回退(refund):回退 used 与写流水同事务,保持「已用量不足则不回退」的现有语义,并保证流水一致性。
  • 事务化初始化(ensureDefaultQuota):新用户默认额度创建与流水写入同事务,利用 uk_user_quota 唯一索引实现并发安全、幂等初始化。
  • 衣物删除回退入事务:ClothesDeleteByUUID 中条件回退 used 与流水写入移入同一事务闭包,消除事务外写流水的丢失窗口。
  • 全面排查补日志:对所有保留的 best-effort 写操作(AI 推荐流水、审核日志、每日计数、通知、缓存等)失败时输出 slog 日志,保证可观测性。
  • 幂等索引自文档化:为 QuotaFlowLog 模型补 (order_no, quota_type, change_type) 联合唯一索引 tag(不改动数据库,仅声明)。

行为变化说明

流水表故障时,配额消耗/回退将整体失败并返回 DatabaseError(用户需重试),这是方案 A 的预期代价,换来账实一致。

技术栈

  • 语言:Go
  • Web 框架:Gin
  • ORM:GORM(事务、clause.OnConflict、clause.Locking)
  • 数据库:MySQL(唯一索引兜底幂等)
  • 日志:log/slog(复用现有 slog.ErrorContext/WarnContext 模式)

实现方案

总体策略

参照项目内已正确的 QuotaGrantIdempotent / QuotaRevokeIdempotent 事务模式(internal/mysqldao/quota.go),在 DAO 层新增三个「事务内条件更新 + 回读 + 写流水」方法,Service 层改为调用事务方法,删除分离的 best-effort 写流水路径。

核心数据流

flowchart LR
    A[业务调用 consumeQuota/refundQuota] --> B[构造 QuotaFlowLog]
    B --> C[DAO 事务开始]
    C --> D[条件更新 used 原子增减]
    D --> E{RowsAffected > 0?}
    E -- 否 --> F[返回 false/nil 额度不足或已用量不足]
    E -- 是 --> G[事务内回读 total/used 填 Old/NewValue]
    G --> H[事务内写入流水]
    H --> I{写流水失败?}
    I -- 是 --> J[整个事务回滚 数值变更撤销 返回错误]
    I -- 否 --> K[事务提交 数值与流水强一致]

关键代码结构

DAO 层新增方法(置于 internal/mysqldao/quota.go):

// QuotaConsumeWithFlow 事务内扣减配额并写流水。
// 条件更新失败(额度不足)返回 (false, nil);流水写入失败整体回滚并返回 error。
func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error)

// QuotaRefundWithFlow 事务内回退配额并写流水。
// 条件更新失败(已用量不足/重复回退)返回 (false, nil);流水失败整体回滚并返回 error。
func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error)

// QuotaInitWithFlow 事务内初始化配额并写流水。
// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing),已存在则跳过返回 nil。
func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error

事务内部模式(仿 QuotaGrantIdempotent):

  1. tx.Model(&Quota{}).Where("user_id=? AND quota_type=? AND used+?<=total").UpdateColumn("used", gorm.Expr("used + ?", n))(消费)或 WHERE used >= ?(回退);RowsAffected==0 直接返回 (false, nil)。
  2. tx.Where(...).First(&cur) 回读真实值,remain := cur.Total - cur.Used,填 flow.OldValue/NewValue。
  3. tx.Create(flow),任何错误返回导致整体回滚。

模块改动

  • internal/mysqldao/quota.go(核心):新增上述三个事务方法;保留 QuotaIncreaseUsed/QuotaDecreaseUsed(供 clothes.go 事务内或未来复用)。
  • internal/service/service.go:consumeQuota/refundQuota 改为构造 flow(含 QuotaFlowUUID、ChangeType、OrderNo、Reason 等)后调用事务方法;false 分支保持原语义(QuotaInsufficient / 告警后返回 nil);删除对 writeQuotaFlow 的分离调用。writeQuotaFlow 在所有调用点迁移完毕后删除。
  • internal/service/clothes.go:DeleteByUUID 将流水写入移入事务闭包——条件更新成功后,在同一 tx 内回读 Quota 并 tx.Create(flow),删除事务外的 refunded 后置写流水逻辑(refunded 变量可保留供逻辑判断)。
  • internal/service/quota.go:ensureDefaultQuota 改为逐类型调用 QuotaInitWithFlow(OnConflict DoNothing),移除先查后插的竞态窗口和分离写流水。
  • internal/mysqlmodel/quotaflowlog.go:为 OrderNo、QuotaType、ChangeType 字段补 uniqueIndex tag 组合(仅自文档化;需同步提示用户在数据库确认该索引真实存在)。
  • P1 补日志:internal/service/airecommend.go(writeFlowLog 与成功流水写入)、internal/service/outfit.go(OutfitAuditLogCreate)、internal/service/closet.go(ClosetAuditLogCreate)、internal/service/service.go(recordDailyUsage)、internal/service/notification.go(sendNotification 的 NotificationCreate 失败分支)、internal/service/user.go(ensureDefaultQuota 调用处)失败时补 slog.WarnContext/ErrorContext(带 user_id、quota_type、error 等关键字段)。

性能与可靠性

  • 每次消耗/回退新增一次回读 + 一次流水插入,均在同一短事务内完成;与现有 grantQuota/revokeQuota 事务模式开销一致,无长事务风险。
  • 保留原子条件更新语义,避免「先查后改」的并发超扣。
  • 日志不打印敏感数据(无 PII/密钥),仅记录业务 ID 与错误信息,避免日志轰炸(这些路径非高频热点)。

验证要点

  • 编译与静态检查:go build ./...、go vet ./...、read_lints 无新增错误。
  • 语义回归核对:consumeQuota 调用方(outfit.go、closet.go、airecommend.go、clothes.go)仍按 QuotaInsufficient/DatabaseError 分支处理;refundQuota 调用方的 _ = 忽略行为不受影响。
  • 数据库检查项:确认 quota_flow_logs 存在 (order_no, quota_type, change_type) 联合唯一索引(幂等兜底前提)。