126 lines
8.5 KiB
Markdown
126 lines
8.5 KiB
Markdown
---
|
||
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)` 联合唯一索引(幂等兜底前提)。 |