退款之后钱货两空:Flask售后退款状态机加库存回滚加原路退回

⚠️ 免责声明 :本文项目仅供技术学习参考,严禁直接作为毕业设计或课程作业提交,更不得用于任何未授权的真实系统测试。所有演示均在本人本地项目上进行。学术诚信、合法合规从我做起 🙂


先说结论

我在自己项目里干过件蠢事:一个 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'))

关键不在公式多精确,在你得有这套逻辑。答辩时能把"整单退和部分退分别怎么处理优惠券"讲明白,比代码写得漂亮管用。


六、一张自检清单

答辩前对着自己的项目逐条过:

  1. 退款是独立表还是订单表的一个字段?
  2. 支持部分退款吗?refunded_amount 有吗?
  3. 退款金额是前端传的还是服务端算的?
  4. 重复提交靠什么拦?代码 if 还是数据库唯一索引?
  5. 未付款、已取消的订单能被退款吗?
  6. 已发货的订单能做"仅退款"吗?
  7. 状态变更有没有白名单校验,还是随手赋值?
  8. 退款成功后库存加回去了吗?部分退款加对了吗?
  9. 金额用的是 Decimal 还是 float?
  10. 优惠券、积分、运费怎么处理,有规则吗?

十条里能过八条,这个模块就比大多数同届的毕设扎实了。


答辩高频题

1. 为什么退款要单独建表,不能在订单表加个状态?

答:一笔订单可能退多次------先退一件、再退另一件,也可能被驳回后重新申请,还可能部分退款。用订单表的一个状态字段无法表达"退了多少、退了几次、每次为什么退、谁审核的"。独立退款单表能承载完整的申请、审核、退货、退款全过程记录,也天然支持一对多的部分退款。

2. 怎么防止用户重复申请退款?

答:靠数据库唯一索引,不靠代码判断。给退款单设计一个幂等键(订单号 + 用户ID + 金额),加唯一约束。并发请求同时进来时,只有一个 INSERT 能成功,其他撞唯一索引返回"已提交"。代码里的 if 判断在并发下一定会被击穿,因为读和写之间有窗口。

3. 部分退款之后,订单状态怎么变?

答:订单状态在退款申请和审核过程中保持不变,只有退款真正完成时才更新 refunded_amount。累退金额达到实付金额才把订单置为已退款/已取消,未达则订单继续正常流程。这样用户撤销申请时不用回滚订单状态,避免状态错乱。

4. 退款金额为什么要用 Decimal?

答:float 是二进制浮点数,无法精确表示 0.1 这类十进制小数,累加多次会产生精度误差,导致账对不上。Decimal 按十进制存储和运算,配合 Numeric(10,2) 数据库字段,能保证金额计算和存储的精确性。这是所有涉及钱的字段的基本要求。

5. 退款成功但异步通知没收到怎么办?

答:不能只依赖异步通知。做法是三层兜底:一是同步返回成功时先记一条"退款中"的中间状态;二是定时任务主动查询退款结果(支付宝有 alipay.trade.fastpay.refund.query 接口),补偿漏掉的通知;三是对账,每天比对本地退款单和支付宝账单,发现不一致人工介入。异步系统必须假设消息会丢。


写在最后

退款这个模块,比下单更能看出一个人的工程水平。

正向流程错了,用户会立刻发现------下单不成功、支付没反应,测试时一定被抓到。

逆向流程错了,往往没人发现。多退一笔钱,只有财务月底对账才看得出;库存少加一次,只有某商品莫名卖不动才知道。

所以这条路,别指望"用户会告诉我"。

你得自己把状态机画出来,把每个跃迁都问一遍"这条路让走吗",再让代码替你把不让走的路堵死。

正向流程考的是能不能做出来,逆向流程考的是有没有想到。


梅雅达编程笔记原创 · 首发 CSDN · 转载请注明出处

相关推荐
这个昵称不容易2 小时前
考试报名系统的设计与实现(SpringBoot + MySQL + Vue 三角色平台)
java·springboot·毕设
软件技术NINI1 天前
基于SpringBoot和Vue的新能源汽车销售推荐系统
java·vue.js·spring boot·后端·汽车·毕业设计·毕设
梅雅达编程笔记1 天前
忘记密码只能找客服:Flask邮箱验证码重置密码加限流防爆破
flask·限流·毕设·电商系统·myd电商·找回密码·邮箱验证码
这个昵称不容易1 天前
旅游管理系统的设计与实现(SpringBoot + MySQL + Vue 前后端分离)
java·springboot·毕设
这个昵称不容易2 天前
SpringBoot 美容院管理系统:232个Java文件315个接口,5套角色从预约到发货一整条链路
java·springboot·毕设
hhcgchpspk2 天前
flask服务端页面预防被爬措施
后端·python·flask·网络爬虫·防爬
这个昵称不容易3 天前
SpringBoot「衣依」服装销售平台:144个Java文件180个接口,从挑衣服到收货评价整条链路都在
java·springboot·毕设
软件技术NINI3 天前
基于SpringBoot和Vue的博物馆预约平台管理系统的设计与实现
java·毕业设计·毕设
这个昵称不容易4 天前
SpringBoot 读书笔记共享平台部署实战:97 个 Java 文件 / 113 个接口 / 10 张表
java·springboot·毕设