支付模块必须用事务保证一致性,金额字段禁用float64,数据库用DECIMAL/NUMERIC,Go中用int64存“分”,且db.Transaction需包裹查余额、扣减、记流水、更状态全链路。

支付模块用 GORM 实现,核心不是“能不能做”,而是“怎么避免资金不一致”——所有操作必须落在事务里,db.Transaction() 不是可选项,是底线。
支付模型设计要避开 float64 字段
数据库里存金额,用 float64 是最常见也最危险的错误。浮点数在二进制下无法精确表示 0.1、0.01 等小数,会导致扣款 9.99 元变成 9.989999999999998,后续对账直接崩。
- 正确做法:数据库字段用
DECIMAL(12,2)(MySQL)或NUMERIC(12,2)(PostgreSQL),Go 结构体对应int64,单位为“分” - 示例结构体:
type Payment struct { ID uint `gorm:"primaryKey"` OrderID string `gorm:"index"` Amount int64 `gorm:"comment:金额,单位为分"` Status string `gorm:"default:'pending';index"` CreatedAt time.Time UpdatedAt time.Time } - 别用
sql.NullFloat64包裹金额字段——它不解决精度问题,反而增加判空负担
db.Transaction() 必须包裹完整业务链路
支付不是单条 UPDATE,而是“查余额 → 扣减 → 记流水 → 更新订单状态”这一串不可分割的动作。漏掉任意一环,就会出现钱扣了但订单没变、或订单变了但钱没扣的情况。
- 不要只包
db.Save()或db.Model().Updates(),要把整个逻辑函数传给db.Transaction() - 示例关键片段:
err := db.Transaction(func(tx *gorm.DB) error { var user User if err := tx.Where("id = ?", userID).First(&user).Error; err != nil { return err } if user.Balance < amount { return errors.New("insufficient balance") } if err := tx.Model(&user).Update("balance", user.Balance-amount).Error; err != nil { return err } if err := tx.Create(&Payment{OrderID: orderID, Amount: amount, Status: "success"}).Error; err != nil { return err } return tx.Model(&Order{}).Where("id = ?", orderID).Update("status", "paid").Error }) - 事务内禁止调用外部 HTTP 请求、发消息、写日志文件——这些操作无法回滚,会破坏原子性
支付状态机必须靠数据库约束兜底
仅靠代码里的 if status == "pending" { status = "paid" } 是脆弱的。并发请求可能让两个 goroutine 同时读到 “pending”,都改成 “paid”,导致重复支付。
- 加数据库唯一索引:比如在
payment表上建UNIQUE INDEX idx_order_id_status ON payment(order_id, status),强制同一订单不能有两条 “pending” 记录 - 更新时带状态条件:
tx.Where("order_id = ? AND status = 'pending'", orderID).Update("status", "paid"),检查影响行数是否为 1 - 软删除字段
deleted_at对支付表意义不大,反而干扰查询,建议不用gorm.Model内嵌,手动定义时间字段
最容易被忽略的一点:支付回调接口收到重复通知时,不能只依赖内存缓存或本地 map 去重。必须用数据库 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)或 INSERT IGNORE(MySQL)做幂等写入,否则服务重启后去重失效,钱就真丢了。


















