V4: 数据出错 —— 「报销金额怎么对不上?」
月底财务对账,发现总金额差了200块。你一查数据库,发现两个问题:
- 重复报销: 同一笔团建费用800元,两个人同时提交了报销申请,结果都被批准了,公司多付了800元。
- 状态矛盾: 有一笔报销,管理员A点了”批准”,管理员B几乎同时点了”驳回”,数据库里状态是”approved”但驳回的操作也返回了成功。
当前状态: V3功能和性能都OK,但数据一致性有漏洞。 目标: 杜绝重复报销,保证并发审批不出现状态矛盾。 约束: 不改前端交互流程,后端加防护。
问题分析(5层框架)
Section titled “问题分析(5层框架)”| 层级 | 问题 | 思考 |
|---|---|---|
| Why | 为什么会出错? | 没有唯一性约束→重复提交;没有并发控制→竞态条件 |
| What | 出了什么错? | 同一笔费用被重复报销;同一笔费用被同时批准和驳回 |
| How (唯一性) | 怎么防重复? | 唯一约束 + 幂等key |
| How (并发) | 怎么防竞态? | 乐观锁(version字段)+ 事务 |
| How (验证) | 怎么确认修复了? | 并发测试:模拟同时提交/同时审批 |
graph LR A["用户A"] --> G["Go/Gin 后端"] B["用户B"] --> G G -->|"事务 + 行锁<br/>SELECT FOR UPDATE"| DB["PostgreSQL"] G --> R["Redis"]数据模型变更
Section titled “数据模型变更”type Expense struct { // ... 原有字段 Version int `json:"version" gorm:"default:1"` // 乐观锁 IdempotencyKey string `json:"idempotency_key" gorm:"uniqueIndex"` // 幂等key
// 唯一约束:同一用户+同一金额+同一日期+同一备注 = 可能是重复提交 // GORM: gorm:"uniqueIndex:idx_unique_expense" UserID uint `gorm:"uniqueIndex:idx_unique_expense,priority:1"` Amount int64 `gorm:"uniqueIndex:idx_unique_expense,priority:2"` Date string `gorm:"uniqueIndex:idx_unique_expense,priority:3"` Note string `gorm:"uniqueIndex:idx_unique_expense,priority:4"`}乐观锁审批流程
Section titled “乐观锁审批流程”1. 前端获取费用详情,拿到 version=12. 管理员A点"批准",发送 PUT /approve { version: 1 }3. 后端执行:UPDATE expenses SET status='approved', version=version+1 WHERE id=? AND version=14. 影响行数=1,成功,version变成25. 管理员B点"驳回",发送 PUT /reject { version: 1 }(他拿到的还是旧版本)6. 后端执行:UPDATE ... WHERE id=? AND version=17. 影响行数=0,说明已被别人修改,返回409 Conflict幂等key设计
Section titled “幂等key设计”// 前端在创建费用时生成一个UUID作为idempotency_key// 即使因网络问题重试,同一个key只会创建一条记录
POST /api/expenses{ "amount": 80000, "category": "团建", "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"}
// 第一次:创建成功,返回201// 第二次(重试):idempotency_key冲突,返回已有记录,返回200| 决策点 | 选项A | 选项B | 选择 | 理由 |
|---|---|---|---|---|
| 并发控制 | 悲观锁(SELECT FOR UPDATE) | 乐观锁(version) | 乐观锁 | 审批冲突概率低,乐观锁不阻塞读 |
| 防重复提交 | 前端禁用按钮 | 后端唯一约束 | 两者都要 | 前端防君子,后端防意外 |
| 幂等实现 | 业务字段组合唯一 | idempotency_key | 两者都要 | 业务唯一防逻辑重复,幂等key防网络重试 |
| 事务隔离级别 | Read Committed | Serializable | Read Committed | PostgreSQL默认级别,配合乐观锁足够 |
| 冲突响应 | 静默成功 | 409 Conflict | 409 | 让前端知道发生了冲突,可以提示用户刷新 |
给AI的Prompt
Section titled “给AI的Prompt”V3的记账系统出现了数据一致性问题,请帮我修复:
## 问题1:重复报销同一笔费用(同用户、同金额、同日期、同备注)可以被创建多次。
修复方案:1. Expense表加唯一复合约束 (user_id, amount, date, note)2. 新增 idempotency_key 字段(string, 唯一索引),前端生成UUID传入3. 创建费用时: - 如果idempotency_key已存在,返回已有记录(200),不报错 - 如果业务字段组合重复,返回409 Conflict并提示"相似费用已存在"
## 问题2:并发审批状态矛盾两个管理员同时审批同一笔费用,一个批准一个驳回,都返回成功。
修复方案:1. Expense表新增 version 字段(int, 默认1)2. 审批接口要求前端传入当前version3. UPDATE语句加 WHERE version = ? 条件4. 如果影响行数为0,说明已被其他人修改,返回409 Conflict5. 整个审批操作包在事务里: - 开始事务 - UPDATE expenses SET status=?, version=version+1 WHERE id=? AND status='pending' AND version=? - 检查影响行数 - 提交/回滚事务
## 具体实现要求
### Expense模型变更```gotype Expense struct { // 保留原有字段... Version int `json:"version" gorm:"default:1"` IdempotencyKey string `json:"idempotency_key" gorm:"uniqueIndex"`}// 另外加唯一复合索引 (user_id, amount, date, note)创建费用接口修改
Section titled “创建费用接口修改”func CreateExpense(c *gin.Context) { // 1. 检查idempotency_key是否已存在 // 存在→返回已有记录(200) // 2. 尝试创建 // 唯一约束冲突→返回409 "相似费用已存在,请确认是否重复" // 3. 成功→返回201}审批接口修改
Section titled “审批接口修改”func ApproveExpense(c *gin.Context) { // 1. 从请求body获取version // 2. 开始事务 // 3. UPDATE expenses SET status='approved', version=version+1 // WHERE id=? AND status='pending' AND version=? // 4. 检查RowsAffected // 0 → 回滚,返回409 "该费用已被其他人处理,请刷新页面" // 1 → 提交,返回200 // 5. 清除Dashboard缓存}- 创建费用时用crypto.randomUUID()生成idempotency_key
- 费用详情接口返回version字段
- 审批请求带上version
- 收到409时弹提示”该费用已被处理,请刷新”并自动刷新列表
请保持V3所有功能和性能优化不变。
---
## 验证清单
- [ ] 同一idempotency_key提交两次,第二次返回200和已有记录- [ ] 同用户+同金额+同日期+同备注提交,返回409提示重复- [ ] 管理员A批准后,管理员B用旧version驳回返回409- [ ] 409响应包含明确的提示信息- [ ] 审批操作在事务中执行(要么全成功,要么全回滚)- [ ] 已批准/已驳回的费用不能再次审批(status='pending'条件)- [ ] version字段在每次审批后+1- [ ] 前端收到409后正确提示用户并刷新- [ ] 并发测试:10个goroutine同时审批同一笔费用,只有1个成功
---
## 你学到了什么
| 知识点 | 对应模块 ||--------|----------|| 乐观锁原理:version字段 + CAS操作 | → Module 6(事务与锁) || 数据库事务:BEGIN/COMMIT/ROLLBACK | → Module 6(事务与锁) || RowsAffected检查确认更新成功 | → Module 6(事务与锁) || 幂等性:idempotency_key防重复请求 | → Module 7(幂等) || 唯一约束防业务重复 | → Module 6(事务与锁) || 409 Conflict的正确使用场景 | → Module 7(幂等) |
---
## 常见踩坑
### 1. 乐观锁忘记检查RowsAffected```go// 错误:只执行UPDATE,不检查结果db.Model(&expense).Where("version = ?", version). Updates(map[string]interface{}{"status": "approved", "version": gorm.Expr("version + 1")})// 即使version不匹配(0行被更新),也没报错
// 正确:必须检查RowsAffectedresult := db.Model(&expense).Where("version = ?", version). Updates(map[string]interface{}{"status": "approved", "version": gorm.Expr("version + 1")})if result.RowsAffected == 0 { c.JSON(409, gin.H{"error": "该费用已被其他人处理,请刷新页面"}) return}2. 事务里忘记回滚
Section titled “2. 事务里忘记回滚”// 错误:出错后直接return,事务没回滚tx := db.Begin()result := tx.Model(&expense).Updates(...)if result.RowsAffected == 0 { c.JSON(409, ...) return // 事务泄漏!连接不会释放}
// 正确:用defer保证回滚tx := db.Begin()defer func() { if r := recover(); r != nil { tx.Rollback() }}()// ... 操作 ...if result.RowsAffected == 0 { tx.Rollback() c.JSON(409, ...) return}tx.Commit()3. 幂等key的唯一约束冲突被当成错误
Section titled “3. 幂等key的唯一约束冲突被当成错误”// 错误:唯一约束冲突直接返回500err := db.Create(&expense).Errorif err != nil { c.JSON(500, gin.H{"error": err.Error()}) // 泄露数据库错误信息 return}
// 正确:区分幂等重试和真正的错误err := db.Create(&expense).Errorif err != nil { if strings.Contains(err.Error(), "idempotency_key") { // 幂等重试,返回已有记录 var existing Expense db.Where("idempotency_key = ?", input.IdempotencyKey).First(&existing) c.JSON(200, existing) return } if strings.Contains(err.Error(), "idx_unique_expense") { c.JSON(409, gin.H{"error": "相似费用已存在,请确认是否重复"}) return } c.JSON(500, gin.H{"error": "创建失败"}) // 不暴露内部错误}4. 唯一约束太严格或太宽松
Section titled “4. 唯一约束太严格或太宽松”-- 太严格:note不同就不算重复(用户换个备注就能重复报销)UNIQUE (user_id, amount, date, note)
-- 太宽松:同一天同金额就算重复(可能确实有两笔同金额的不同开支)UNIQUE (user_id, amount, date)
-- 实际中需要根据业务判断,可能还需要人工审核-- 唯一约束是第一道防线,不是唯一防线5. 前端不传version导致乐观锁形同虚设
Section titled “5. 前端不传version导致乐观锁形同虚设”// 错误:前端没带versionfetch(`/api/admin/expenses/${id}/approve`, { method: 'PUT', body: JSON.stringify({}) // 没有version!})
// 正确:必须带上当前versionfetch(`/api/admin/expenses/${id}/approve`, { method: 'PUT', body: JSON.stringify({ version: expense.version })})
// 后端也要校验:version必传if input.Version == 0 { c.JSON(400, gin.H{"error": "version is required"}) return}6. 并发测试不写等于没修
Section titled “6. 并发测试不写等于没修”// 写个测试验证并发安全func TestConcurrentApproval(t *testing.T) { // 创建一笔pending费用 expense := createTestExpense(t)
var wg sync.WaitGroup successCount := int32(0)
for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() // 所有goroutine用同一个version尝试审批 resp := approveExpense(expense.ID, expense.Version) if resp.StatusCode == 200 { atomic.AddInt32(&successCount, 1) } }() }
wg.Wait() assert.Equal(t, int32(1), successCount) // 只有1个成功}