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

126 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: 配额流水事务一致性修复
overview: 按方案 A 将「配额数值变更」与「写配额流水」合并到同一数据库事务,消除 best-effort 静默丢流水导致的账实不符(配额泄漏)风险,并全面排查同类问题、为其余 best-effort 写操作补齐日志。
todos:
- id: dao-transactional-methods
content: 在 internal/mysqldao/quota.go 新增 QuotaConsumeWithFlow、QuotaRefundWithFlow、QuotaInitWithFlow 三个事务方法,并为 QuotaFlowLog 模型补唯一索引 tag
status: completed
- id: service-consume-refund
content: 改造 internal/service/service.go 的 consumeQuota 与 refundQuota 调用新事务方法,删除分离的 writeQuotaFlow 调用及函数
status: completed
dependencies:
- dao-transactional-methods
- id: clothes-delete-transaction
content: 将 internal/service/clothes.go DeleteByUUID 的流水写入移入既有事务闭包,消除事务外写流水窗口
status: completed
dependencies:
- dao-transactional-methods
- id: ensure-default-quota-transaction
content: 将 internal/service/quota.go ensureDefaultQuota 改用 QuotaInitWithFlow 实现并发安全幂等初始化
status: completed
dependencies:
- dao-transactional-methods
- id: best-effort-logging
content: 为 airecommend.go、outfit.go、closet.go、notification.go、service.go recordDailyUsage、user.go 等保留的 best-effort 写操作补失败日志
status: completed
- id: build-verify
content: 执行 go build、go vet 与 lint 检查,回归核对所有 consumeQuota/refundQuota 调用点返回语义
status: completed
dependencies:
- 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 写流水路径。
### 核心数据流
```mermaid
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)` 联合唯一索引(幂等兜底前提)。