跳转到内容

V4: 数据出错 —— 「报销金额怎么对不上?」

月底财务对账,发现总金额差了200块。你一查数据库,发现两个问题:

  1. 重复报销: 同一笔团建费用800元,两个人同时提交了报销申请,结果都被批准了,公司多付了800元。
  2. 状态矛盾: 有一笔报销,管理员A点了”批准”,管理员B几乎同时点了”驳回”,数据库里状态是”approved”但驳回的操作也返回了成功。

当前状态: V3功能和性能都OK,但数据一致性有漏洞。 目标: 杜绝重复报销,保证并发审批不出现状态矛盾。 约束: 不改前端交互流程,后端加防护。


层级问题思考
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"]
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"`
}
1. 前端获取费用详情,拿到 version=1
2. 管理员A点"批准",发送 PUT /approve { version: 1 }
3. 后端执行:UPDATE expenses SET status='approved', version=version+1
WHERE id=? AND version=1
4. 影响行数=1,成功,version变成2
5. 管理员B点"驳回",发送 PUT /reject { version: 1 }(他拿到的还是旧版本)
6. 后端执行:UPDATE ... WHERE id=? AND version=1
7. 影响行数=0,说明已被别人修改,返回409 Conflict
// 前端在创建费用时生成一个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 CommittedSerializableRead CommittedPostgreSQL默认级别,配合乐观锁足够
冲突响应静默成功409 Conflict409让前端知道发生了冲突,可以提示用户刷新

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. 审批接口要求前端传入当前version
3. UPDATE语句加 WHERE version = ? 条件
4. 如果影响行数为0,说明已被其他人修改,返回409 Conflict
5. 整个审批操作包在事务里:
- 开始事务
- UPDATE expenses SET status=?, version=version+1 WHERE id=? AND status='pending' AND version=?
- 检查影响行数
- 提交/回滚事务
## 具体实现要求
### Expense模型变更
```go
type Expense struct {
// 保留原有字段...
Version int `json:"version" gorm:"default:1"`
IdempotencyKey string `json:"idempotency_key" gorm:"uniqueIndex"`
}
// 另外加唯一复合索引 (user_id, amount, date, note)
func CreateExpense(c *gin.Context) {
// 1. 检查idempotency_key是否已存在
// 存在→返回已有记录(200)
// 2. 尝试创建
// 唯一约束冲突→返回409 "相似费用已存在,请确认是否重复"
// 3. 成功→返回201
}
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行被更新),也没报错
// 正确:必须检查RowsAffected
result := 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
}
// 错误:出错后直接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的唯一约束冲突被当成错误”
// 错误:唯一约束冲突直接返回500
err := db.Create(&expense).Error
if err != nil {
c.JSON(500, gin.H{"error": err.Error()}) // 泄露数据库错误信息
return
}
// 正确:区分幂等重试和真正的错误
err := db.Create(&expense).Error
if 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": "创建失败"}) // 不暴露内部错误
}
-- 太严格:note不同就不算重复(用户换个备注就能重复报销)
UNIQUE (user_id, amount, date, note)
-- 太宽松:同一天同金额就算重复(可能确实有两笔同金额的不同开支)
UNIQUE (user_id, amount, date)
-- 实际中需要根据业务判断,可能还需要人工审核
-- 唯一约束是第一道防线,不是唯一防线

5. 前端不传version导致乐观锁形同虚设

Section titled “5. 前端不传version导致乐观锁形同虚设”
// 错误:前端没带version
fetch(`/api/admin/expenses/${id}/approve`, {
method: 'PUT',
body: JSON.stringify({}) // 没有version!
})
// 正确:必须带上当前version
fetch(`/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
}
// 写个测试验证并发安全
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个成功
}