8.5 KiB
8.5 KiB
name, overview, todos
| name | overview | todos | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 配额流水事务一致性修复 | 按方案 A 将「配额数值变更」与「写配额流水」合并到同一数据库事务,消除 best-effort 静默丢流水导致的账实不符(配额泄漏)风险,并全面排查同类问题、为其余 best-effort 写操作补齐日志。 |
|
产品概述
修复配额流水与配额数值不一致导致的「配额泄漏」问题。核心思路(方案 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):
tx.Model(&Quota{}).Where("user_id=? AND quota_type=? AND used+?<=total").UpdateColumn("used", gorm.Expr("used + ?", n))(消费)或WHERE used >= ?(回退);RowsAffected==0直接返回(false, nil)。tx.Where(...).First(&cur)回读真实值,remain := cur.Total - cur.Used,填flow.OldValue/NewValue。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字段补uniqueIndextag 组合(仅自文档化;需同步提示用户在数据库确认该索引真实存在)。- 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)联合唯一索引(幂等兜底前提)。