⚠️ 免责声明 :本文项目仅供技术学习参考,严禁直接作为毕业设计或课程作业提交,更不得用于任何未授权的真实系统测试。所有演示均在本人本地项目上进行。学术诚信、合法合规从我做起 🙂
先说结论
我在自己项目里干过件蠢事:一个 199 元的订单,连点三次"申请退款"。
结果退了三次,598 块。
原因不复杂。我在订单表加了个 is_refund 字段,点一次改 1,接口里判断"已经是 1 就不给退"。可三次请求几乎是同时到的,三次都读到了 is_refund = 0。
这事让我想明白一句:退款不是订单的一个状态,它是一张独立的单子。
一笔订单可能退一次全款,也可能分两次退部分款------先退一件,再退另一件;还可能被驳回后重新申请。用一个布尔字段去表达这些,怎么都表达不完。
这篇按"状态机怎么画 → 非法跃迁怎么堵 → 库存和钱怎么还"来写。
一、先把两张表分开
订单表
python
class OrderInfo(db.Model):
__tablename__ = 'order_info'
id = db.Column(db.Integer, primary_key=True)
order_no = db.Column(db.String(32), unique=True, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
total_amount = db.Column(db.Numeric(10, 2), nullable=False) # 应付
pay_amount = db.Column(db.Numeric(10, 2), default=0) # 实付
refunded_amount = db.Column(db.Numeric(10, 2), default=0) # 已退
status = db.Column(db.SmallInteger, default=0)
# 0 待付款 1 待发货 2 待收货 3 已完成 4 已取消
pay_time = db.Column(db.DateTime)
create_time = db.Column(db.DateTime, default=datetime.now)
refunded_amount 这个字段很关键。它让"部分退款"成为可能------退了多少、还剩多少可退,一笔账算得清。只靠一个 is_refund 布尔值,永远做不了部分退款。
退款单表
python
class RefundOrder(db.Model):
__tablename__ = 'refund_order'
id = db.Column(db.Integer, primary_key=True)
refund_no = db.Column(db.String(32), unique=True, nullable=False)
order_id = db.Column(db.Integer, db.ForeignKey('order_info.id'),
nullable=False)
user_id = db.Column(db.Integer, nullable=False)
refund_amount = db.Column(db.Numeric(10, 2), nullable=False)
reason = db.Column(db.String(200))
images = db.Column(db.Text) # 凭证图片,逗号分隔
status = db.Column(db.SmallInteger, default=0)
# 0 待审核 1 审核通过待退货 2 已驳回
# 3 退货中 4 已退款 5 已关闭
refund_type = db.Column(db.SmallInteger, default=0)
# 0 仅退款 1 退货退款
audit_remark = db.Column(db.String(200))
audit_time = db.Column(db.DateTime)
refund_time = db.Column(db.DateTime)
create_time = db.Column(db.DateTime, default=datetime.now)
# ✅ 幂等键:同一笔订单的同一个退款请求只能有一条
idempotent_key = db.Column(db.String(64), unique=True)
最后那个 idempotent_key,就是拿来堵我上面"退三次"那个坑的,后面细说。
二、把状态机画出来
订单状态和退款单状态是两条线,别混在一起改。订单状态只在退款真正完成那一刻才变。
退款单的合法跃迁
| 当前状态 | 允许流向 | 触发动作 |
|---|---|---|
| 0 待审核 | 1 通过 / 2 驳回 | 管理员审核 |
| 1 通过待退货 | 3 退货中 / 4 已退款 | 用户寄回 / 无需退货直接退 |
| 3 退货中 | 4 已退款 | 管理员确认收货 |
| 0 待审核 | 5 已关闭 | 用户主动撤销 |
| 4 / 5 / 2 | ------ | 终态,不可再变 |
把这套流转写成代码里的白名单,比堆一堆 if 靠谱:
python
REFUND_TRANSITIONS = {
0: {1, 2, 5},
1: {3, 4},
3: {4},
2: set(),
4: set(),
5: set(),
}
def can_transit(from_status, to_status):
return to_status in REFUND_TRANSITIONS.get(from_status, set())
订单状态什么时候动
这是最容易写错的地方。很多毕设一提交退款申请就把订单改成"退款中",用户一撤销申请,订单状态就回不去。
规则:申请退款不动订单状态,只有退款真正完成(退款单到状态 4)才动。
python
def on_refund_success(refund):
order = OrderInfo.query.get(refund.order_id)
order.refunded_amount = (order.refunded_amount or 0) + refund.refund_amount
# 退完了,订单变已取消(或"已退款",看你状态设计)
if order.refunded_amount >= order.pay_amount:
order.status = 4
# 部分退款,订单状态不动,继续正常流程
三、六个非法跃迁,逐个堵
跃迁 1:没付钱的订单申请退款
python
# ❌ 只判断了订单存在
order = OrderInfo.query.get_or_404(order_id)
refund = RefundOrder(order_id=order.id, refund_amount=order.total_amount)
未付款的订单,用户该做的是"取消",不是"退款"。这条放过去,等于凭空给用户退一笔他从来没付过的钱。
python
# ✅ 只允许已付款的订单走退款
if order.status not in (1, 2, 3):
return jsonify({'code': 1, 'message': '当前订单状态不支持退款'})
if not order.pay_time:
return jsonify({'code': 1, 'message': '订单未支付,无法退款'})
跃迁 2:退款金额超过实付
这条最直白,也最容易被绕过------因为太多人把金额交给前端传。
python
# ❌ 前端传多少就退多少
refund_amount = Decimal(request.json.get('amount'))
python
# ✅ 服务端算,不信任前端
refund_amount = Decimal(str(request.json.get('amount', 0)))
max_refundable = order.pay_amount - (order.refunded_amount or 0)
if refund_amount <= 0:
return jsonify({'code': 1, 'message': '退款金额必须大于 0'})
if refund_amount > max_refundable:
return jsonify({'code': 1,
'message': f'最多可退 {max_refundable} 元'})
用 Decimal,别用 float。float 算钱会蹦出 0.1 + 0.2 = 0.30000000000000004 这种事,金额一多就攒出几分钱误差,对不上账。
跃迁 3:重复提交,退三次
这就是我踩的那个坑。三个并发的 POST,全通过了"能不能退"的校验。
唯一靠谱的解法是数据库层面的唯一约束,不是代码里的 if。
python
# 幂等键:订单号 + 用户ID + 金额 + 一个短时间窗口
def build_idempotent_key(order_no, user_id, amount):
return f'{order_no}:{user_id}:{amount}'
@app.route('/refund/apply', methods=['POST'])
@login_required
def refund_apply():
order_no = request.json.get('order_no')
amount = Decimal(str(request.json.get('amount', 0)))
order = OrderInfo.query.filter_by(
order_no=order_no, user_id=current_user.id
).first()
if not order:
return jsonify({'code': 1, 'message': '订单不存在'})
# ... 上面那些状态和金额校验 ...
key = build_idempotent_key(order_no, current_user.id, amount)
refund = RefundOrder(
refund_no=gen_refund_no(),
order_id=order.id,
user_id=current_user.id,
refund_amount=amount,
idempotent_key=key,
)
db.session.add(refund)
try:
db.session.commit()
except IntegrityError:
# ✅ 唯一索引冲突,说明这个请求已经进来过了
db.session.rollback()
exist = RefundOrder.query.filter_by(idempotent_key=key).first()
return jsonify({'code': 0,
'message': '申请已提交,请勿重复操作',
'refund_no': exist.refund_no})
return jsonify({'code': 0, 'refund_no': refund.refund_no})
三个请求同时进来,数据库只让一个 INSERT 成功,另外两个撞唯一索引,返回"已提交"。这套方案不依赖任何代码逻辑对不对,只依赖数据库的约束------这才是靠得住的幂等。
跃迁 4:已发货的订单走"仅退款"
python
# ✅ 仅退款只在未发货时可用
if refund_type == 0 and order.status == 2:
return jsonify({'code': 1,
'message': '商品已发出,请选择退货退款'})
这条不加,用户能拿到钱还留着货。真实业务里,这是最常见的薅羊毛手法。
跃迁 5:驳回之后又自动通过
python
# ❌ 没有状态校验,管理员手抖点两次就出问题
refund.status = 1
db.session.commit()
python
# ✅ 所有状态变更都走同一道闸
def change_refund_status(refund, to_status, remark=None):
if not can_transit(refund.status, to_status):
raise ValueError(
f'非法状态流转:{refund.status} → {to_status}'
)
refund.status = to_status
if remark:
refund.audit_remark = remark
refund.audit_time = datetime.now()
db.session.commit()
有了这道闸,驳回(状态 2)就是终态,任何想往 1 走的尝试都会被拦下。
跃迁 6:退款成功了,库存没回来
python
def on_refund_success(refund):
order = OrderInfo.query.get(refund.order_id)
# ✅ 整单退,库存按订单明细逐项加回
if refund.refund_amount >= order.pay_amount - (order.refunded_amount or 0):
for item in order.items:
db.session.execute(
text("UPDATE goods SET stock = stock + :n WHERE id = :gid"),
{'n': item.quantity, 'gid': item.goods_id}
)
order.refunded_amount = (order.refunded_amount or 0) + refund.refund_amount
if order.refunded_amount >= order.pay_amount:
order.status = 4
部分退款要不要回库存? 看业务。退了一件商品,库存加回 1;少发了补偿 20 块,货没回来,库存不能动。
我的做法:退款单带一个 return_stock 标记,审核时勾选,代码只认这个标记:
python
if refund.return_stock:
for item in refund.items: # 部分退款要指明退哪几件
db.session.execute(
text("UPDATE goods SET stock = stock + :n WHERE id = :gid"),
{'n': item.quantity, 'gid': item.goods_id}
)
四、钱怎么回去:原路退回
毕设阶段用支付宝沙箱,接口是 alipay.trade.refund。关键三件事:
一是必须用原交易的 trade_no,不是你自己生成的订单号。
python
def do_refund(refund):
order = OrderInfo.query.get(refund.order_id)
result = alipay_client.api_alipay_trade_refund(
out_trade_no=order.order_no, # 正向支付时传给支付宝的订单号
refund_amount=str(refund.refund_amount),
out_request_no=refund.refund_no, # ✅ 退款请求号,用于幂等
refund_reason=refund.reason or '用户申请退款'
)
if result.get('code') != '10000':
raise RuntimeError(f'退款失败: {result.get("sub_msg")}')
# 退款成功,更新状态
change_refund_status(refund, 4)
refund.refund_time = datetime.now()
on_refund_success(refund)
out_request_no 是支付宝侧的幂等键。同一个退款请求号重复提交,支付宝只会退一次。你这边有唯一索引,支付宝那边有请求号,两道保险。
二是退款金额精度必须和订单一致。 支付宝要两位小数,Decimal('199.9') 传过去会被拒,得先 .quantize(Decimal('0.01'))。
三是必须处理异步通知。 同步返回 code == '10000' 不代表钱一定到账,最终状态以异步通知为准。毕设里至少把通知接口写出来、做验签、结果落库------这条 10 篇讲过,退款这边同理。
五、优惠券和积分怎么办
这是答辩老师特别爱追问的一点,因为大部分毕设压根没想过。
规则一句话:退款时按比例退优惠,或者不退。
| 场景 | 处理 |
|---|---|
| 整单退款 | 优惠券退回用户账户(如果还在有效期内) |
| 部分退款 | 按退款金额占实付比例,折算退回 |
| 券已过期 | 不退回,或者退回一张同面额新券(业务决定) |
| 积分抵扣 | 按比例退回积分 |
python
def calc_coupon_refund(order, refund_amount):
"""按比例折算应退的优惠金额"""
if not order.coupon_amount or order.pay_amount == 0:
return Decimal('0.00')
ratio = refund_amount / order.pay_amount
return (order.coupon_amount * ratio).quantize(Decimal('0.01'))
关键不在公式多精确,在你得有这套逻辑。答辩时能把"整单退和部分退分别怎么处理优惠券"讲明白,比代码写得漂亮管用。
六、一张自检清单
答辩前对着自己的项目逐条过:
- 退款是独立表还是订单表的一个字段?
- 支持部分退款吗?
refunded_amount有吗? - 退款金额是前端传的还是服务端算的?
- 重复提交靠什么拦?代码 if 还是数据库唯一索引?
- 未付款、已取消的订单能被退款吗?
- 已发货的订单能做"仅退款"吗?
- 状态变更有没有白名单校验,还是随手赋值?
- 退款成功后库存加回去了吗?部分退款加对了吗?
- 金额用的是
Decimal还是float? - 优惠券、积分、运费怎么处理,有规则吗?
十条里能过八条,这个模块就比大多数同届的毕设扎实了。
答辩高频题
1. 为什么退款要单独建表,不能在订单表加个状态?
答:一笔订单可能退多次------先退一件、再退另一件,也可能被驳回后重新申请,还可能部分退款。用订单表的一个状态字段无法表达"退了多少、退了几次、每次为什么退、谁审核的"。独立退款单表能承载完整的申请、审核、退货、退款全过程记录,也天然支持一对多的部分退款。
2. 怎么防止用户重复申请退款?
答:靠数据库唯一索引,不靠代码判断。给退款单设计一个幂等键(订单号 + 用户ID + 金额),加唯一约束。并发请求同时进来时,只有一个 INSERT 能成功,其他撞唯一索引返回"已提交"。代码里的 if 判断在并发下一定会被击穿,因为读和写之间有窗口。
3. 部分退款之后,订单状态怎么变?
答:订单状态在退款申请和审核过程中保持不变,只有退款真正完成时才更新 refunded_amount。累退金额达到实付金额才把订单置为已退款/已取消,未达则订单继续正常流程。这样用户撤销申请时不用回滚订单状态,避免状态错乱。
4. 退款金额为什么要用 Decimal?
答:float 是二进制浮点数,无法精确表示 0.1 这类十进制小数,累加多次会产生精度误差,导致账对不上。Decimal 按十进制存储和运算,配合 Numeric(10,2) 数据库字段,能保证金额计算和存储的精确性。这是所有涉及钱的字段的基本要求。
5. 退款成功但异步通知没收到怎么办?
答:不能只依赖异步通知。做法是三层兜底:一是同步返回成功时先记一条"退款中"的中间状态;二是定时任务主动查询退款结果(支付宝有 alipay.trade.fastpay.refund.query 接口),补偿漏掉的通知;三是对账,每天比对本地退款单和支付宝账单,发现不一致人工介入。异步系统必须假设消息会丢。
写在最后
退款这个模块,比下单更能看出一个人的工程水平。
正向流程错了,用户会立刻发现------下单不成功、支付没反应,测试时一定被抓到。
逆向流程错了,往往没人发现。多退一笔钱,只有财务月底对账才看得出;库存少加一次,只有某商品莫名卖不动才知道。
所以这条路,别指望"用户会告诉我"。
你得自己把状态机画出来,把每个跃迁都问一遍"这条路让走吗",再让代码替你把不让走的路堵死。
正向流程考的是能不能做出来,逆向流程考的是有没有想到。
梅雅达编程笔记原创 · 首发 CSDN · 转载请注明出处