导读(3行收益):储值卡系统最常见的三个事故:余额被并发扣成负数、充值到账和流水对不上、过期卡处理没章法。根源是把余额当成了一个字段而不是一套账。本文用「流水+余额」两层模型把它理顺,附建表 SQL、扣款保护和状态迁移表。看完能直接改进你的储值卡设计。
一、储值卡系统的三个典型事故
- 余额被扣成负数:并发扣款时两个请求都读到同一个余额,一起扣,账就负了;
- 充值对不上:充值成功后没写流水,或流水重复写,月底对账对不平;
- 过期处理乱:过期卡还能不能扣、余额怎么处理、退款还是清零,全靠人工拍脑袋。
三个事故的共同根源:余额只有一个字段,没有流水账。谁都能改、改了没痕迹,出问题无法追溯。正确做法是「流水为凭、余额为读」——一切余额变化都必须有一条流水记录支撑。
二、流水+余额:两层模型
-- 储值卡主表:只存当前余额与状态
CREATE TABLE stored_card (
id BIGINT PRIMARY KEY,
member_id BIGINT NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0, -- 当前可用余额
status TINYINT NOT NULL DEFAULT 0,
-- 0正常 1冻结 2挂失 3已过期 4已注销
opened_at DATETIME,
expired_at DATETIME
);
-- 流水表:余额的每一次变化都有凭据
CREATE TABLE card_flow (
id BIGINT PRIMARY KEY,
card_id BIGINT NOT NULL,
flow_type TINYINT NOT NULL, -- 1开卡 2充值 3消费 4退款 5过期清零 6手工调整
amount DECIMAL(10,2) NOT NULL, -- 正为入账、负为出账
balance_after DECIMAL(10,2) NOT NULL, -- 变化后余额,用于对账
biz_no VARCHAR(64) NOT NULL, -- 业务单号(充值单/订单号)
created_at DATETIME
);
CREATE UNIQUE INDEX uk_flow_biz ON card_flow(biz_no);
核心规则:任何余额变动必须同时插入一条流水,且 biz_no 唯一——重复的充值单、重复的扣款单会被唯一索引挡住,账永远对得平。流水表只写不改:一旦落库不允许 UPDATE 或 DELETE,记错账用「负数冲正流水」纠正,保证账实相符、可审计。
三、开卡与充值:入账幂等
充值是「钱先进来」的动作,必须幂等:同一笔充值单只入账一次。
def recharge(card_id: int, biz_no: str, amount: Decimal) -> bool:
try:
with db.transaction():
# 先插流水,唯一索引防重复入账
db.execute(
"INSERT INTO card_flow(card_id, flow_type, amount, biz_no) "
"VALUES(?, 2, ?, ?)",
card_id, amount, biz_no,
)
# 再更新余额
db.execute(
"UPDATE stored_card SET balance = balance + ? WHERE id = ?",
amount, card_id,
)
return True
except IntegrityError:
return False # 该充值单已入账
避坑:先插流水、后更余额,放在同一事务里;如果先更余额再插流水,流水插入失败时余额已变,对不上。插入流水用 INSERT 而不是 INSERT IGNORE,让唯一索引把重复单显式挡下来。
四、扣款:余额保护与 CAS
扣款必须保证余额不为负,用条件更新(CAS)解决并发:
def consume(card_id: int, biz_no: str, amount: Decimal) -> bool:
affected = db.execute(
"UPDATE stored_card SET balance = balance - :amt "
"WHERE id = :id AND balance >= :amt AND status = 0",
amt=amount, id=card_id,
).rowcount
if affected != 1:
return False # 余额不足或卡状态不对
db.execute(
"INSERT INTO card_flow(card_id, flow_type, amount, biz_no) "
"VALUES(?, 3, ?, ?)",
card_id, -amount, biz_no,
)
return True
避坑:扣款先 CAS 更新余额、再插流水;不要先查余额再扣(有窗口期,两个请求会同时通过)。扣款失败的提示要区分「余额不足」和「卡状态异常」,别都返回同一个错误。订单退款时用 flow_type=4 的负数流水冲正,而不是直接改原扣款流水。
五、过期与冻结:状态机
储值卡状态迁移表(可直接抄走):
| 当前状态 | 事件 | 目标状态 | 处理 |
|---|---|---|---|
| 正常 | 到有效期 | 已过期 | 定时任务清零余额并记流水,或转冻结待处理 |
| 正常/已过期 | 后台冻结 | 冻结 | 暂停扣款,可解冻 |
| 正常 | 用户挂失 | 挂失 | 暂停扣款,补卡后迁移余额 |
| 正常/冻结 | 后台注销 | 已注销 | 余额转出或清零,流水留痕 |
过期处理建议:过期不等于自动清零——先转冻结、给用户留退款/续期窗口,期满再清零,每步都记流水,避免客诉和账务纠纷。
六、踩坑清单
- 余额单字段无流水:任何异常都无法追溯,对账全靠猜;
- 充值先更余额后插流水:流水失败余额已变,账对不平;
- 扣款先查后扣:并发窗口期会把余额扣成负数;
- 过期直接删卡:账没了、客诉来了,应状态化处理;
- 流水与业务单号不唯一:重复入账重复扣款,月底对账炸;
- 手工调整不留流水:财务审计时找不到依据。
七、工程落地建议
储值卡建议按「流水+余额+状态机」三层建模,先保证充值入账与扣款两条链路的幂等,再做过期、挂失等状态分支。若从零搭成本高,可基于成型平台(如乔拓云门店系统)的会员与储值组件快速起步,重点核对流水完整性与状态流转。
八、复盘清单(可直接抄走)
- [ ] 充值链路:唯一索引 + 先流水后余额是否都在;
- [ ] 扣款链路:CAS 更新 + 状态判断是否覆盖所有入口;
- [ ] 状态机:冻结/挂失/过期/注销分支是否都有对应处理;
- [ ] 对账:每日流水合计与余额变化是否一致;
- [ ] 监控:余额为负、重复入账是否有告警。
结语
储值卡的核心不是「记一个余额」,而是「每一分钱都有流水、每一次变化都有状态」。两层模型加状态机,三个常见事故就能按下去。本文仅作技术分享,具体功能以各平台官方实时信息为准。