1.5万机票只退432?聊聊代购系统里的退款逻辑与订单状态机
看到"花1.5万买机票退票仅退432元"这条热搜,我笑了。不是幸灾乐祸,是因为做代购系统这几年,退款这事儿我太懂了。看起来简单的"退钱"两个字,背后的逻辑复杂到你想象不到。
今天就聊聊代购系统里的退款设计,以及我踩过的那些坑。
为什么退款这么复杂?
你可能会说,退款不就是把钱退回去吗?有什么难的。那是你没做过代购系统。
普通电商的退款:用户申请 → 商家审核 → 退款完成。就这么简单。
代购系统的退款呢?我给你数数有多少种情况:
用户付款后,还没下单采购,用户要退款 —— 全退
已经下单采购了,但商家还没发货,用户要退款 —— 要扣手续费,还要看商家同不同意退
商家已经发货了,还没到仓库,用户要退款 —— 更麻烦,可能要拒收,或者退回去的运费谁出?
已经到仓库了,用户要退款 —— 商品能二次销售的可以退,定制的不能退
已经发往国外了,用户要退款 —— 基本退不了,除非包裹还能截回来
部分退款 —— 一个订单里有多个商品,只退其中几个
差价退款 —— 买的时候贵了,后来降价了,用户要退差价
运费退款 —— 实际运费比预估的少,要退差价
你看,就退款这一件事,就能拆出这么多场景。如果状态机设计得不好,后期维护能把你逼疯。
订单状态机的设计
我做过的几个代购系统,包括参考taocarts的设计,订单状态机都是核心中的核心。设计得好,后面加功能都很顺;设计得不好,改一个bug出十个bug。
# 用Python实现的订单状态机
# 踩坑记录:
# 1. 状态流转必须是单向的,不能随便跳
# 2. 每个状态变更都要记录日志
# 3. 退款要单独走状态,不能和正常流程混在一起
from enum import Enum
from typing import List, Dict
class OrderStatus(Enum):
# 正常流程状态
PENDING_PAYMENT = "pending_payment" # 待支付
PAID = "paid" # 已支付
PURCHASING = "purchasing" # 采购中
PURCHASED = "purchased" # 已采购
IN_WAREHOUSE = "in_warehouse" # 已入库
SHIPPED = "shipped" # 已发货
DELIVERED = "delivered" # 已签收
# 退款相关状态
REFUND_APPLYING = "refund_applying" # 退款申请中
REFUNDING = "refunding" # 退款中
REFUNDED = "refunded" # 已退款
REFUND_REJECTED = "refund_rejected" # 退款被拒
class OrderStateMachine:
# 允许的状态流转
ALLOWED_TRANSITIONS: Dict[OrderStatus, List[OrderStatus]] = {
OrderStatus.PENDING_PAYMENT: [OrderStatus.PAID, OrderStatus.REFUNDED],
OrderStatus.PAID: [OrderStatus.PURCHASING, OrderStatus.REFUND_APPLYING],
OrderStatus.PURCHASING: [OrderStatus.PURCHASED, OrderStatus.REFUND_APPLYING],
OrderStatus.PURCHASED: [OrderStatus.IN_WAREHOUSE, OrderStatus.REFUND_APPLYING],
OrderStatus.IN_WAREHOUSE: [OrderStatus.SHIPPED, OrderStatus.REFUND_APPLYING],
OrderStatus.SHIPPED: [OrderStatus.DELIVERED, OrderStatus.REFUND_APPLYING],
OrderStatus.DELIVERED: [], # 终态
OrderStatus.REFUND_APPLYING: [OrderStatus.REFUNDING, OrderStatus.REFUND_REJECTED, OrderStatus.PAID],
OrderStatus.REFUNDING: [OrderStatus.REFUNDED],
OrderStatus.REFUNDED: [], # 终态
OrderStatus.REFUND_REJECTED: [OrderStatus.PAID, OrderStatus.PURCHASING],
}
def __init__(self, current_status: OrderStatus):
self.current_status = current_status
def can_transition_to(self, target_status: OrderStatus) -> bool:
"""检查是否可以流转到目标状态"""
allowed = self.ALLOWED_TRANSITIONS.get(self.current_status, [])
return target_status in allowed
def transition(self, target_status: OrderStatus, operator: str, remark: str = "") -> bool:
"""执行状态流转"""
if not self.can_transition_to(target_status):
raise ValueError(
f"不允许从 {self.current_status.value} 流转到 {target_status.value}"
)
# 记录状态变更日志(这个很重要!出问题了能回溯)
self._log_status_change(
from_status=self.current_status,
to_status=target_status,
operator=operator,
remark=remark,
)
self.current_status = target_status
return True
def _log_status_change(self, from_status, to_status, operator, remark):
"""记录状态变更日志"""
# 实际项目中这里要写数据库
print(f"状态变更: {from_status.value} -> {to_status.value}, "
f"操作人: {operator}, 备注: {remark}")
退款金额的计算
退款金额怎么算?这又是个大坑。
我见过最蠢的设计,就是退款金额直接等于订单金额。然后出问题了——用户用了优惠券怎么办?用了积分怎么办?运费怎么算?手续费怎么算?
正确的做法是,退款金额要单独计算,不能直接用订单金额。
def calculate_refund_amount(order, refund_items: list, refund_type: str) -> dict:
"""
计算退款金额
踩坑记录:
1. 优惠券要按比例分摊到每个商品
2. 运费退不退要看情况:还没发货的全退,发了的不退
3. 手续费:不同阶段退款手续费比例不一样
4. 积分抵扣的部分要退积分,不是退钱
"""
total_refund = 0
coupon_refund = 0
points_refund = 0
shipping_refund = 0
service_fee = 0
# 计算商品退款金额
for item in refund_items:
order_item = order.items.get(item["item_id"])
if not order_item:
continue
# 商品本身的金额
item_amount = order_item.price * item["quantity"]
total_refund += item_amount
# 分摊的优惠券
item_coupon = order_item.coupon_share * item["quantity"]
coupon_refund += item_coupon
# 分摊的积分
item_points = order_item.points_share * item["quantity"]
points_refund += item_points
# 运费退款
if refund_type == "full" and order.status == "paid":
# 整单退款且还没发货,退运费
shipping_refund = order.shipping_fee
# 手续费(根据订单状态不同,比例不同)
fee_rates = {
"paid": 0.0, # 刚付款还没采购,不收手续费
"purchasing": 0.05, # 采购中,收5%手续费
"purchased": 0.1, # 已采购,收10%手续费
"in_warehouse": 0.15, # 已入库,收15%手续费
}
fee_rate = fee_rates.get(order.status, 0.2)
service_fee = total_refund * fee_rate
# 实际退款金额 = 商品金额 - 优惠券 - 手续费
actual_refund = total_refund - coupon_refund - service_fee
return {
"total_refund": total_refund, # 商品总金额
"coupon_refund": coupon_refund, # 退回优惠券
"points_refund": points_refund, # 退回积分
"shipping_refund": shipping_refund, # 退运费
"service_fee": service_fee, # 手续费
"actual_refund": actual_refund, # 实际退给用户的钱
}
异步退款的坑
还有个坑很多人踩过:同步退款。
就是用户点了退款,你直接调用支付接口退款,然后等结果。这在量小的时候没问题,量大了就完蛋。支付接口有时候会超时,或者网络波动,你这边超时了,那边实际退款成功了,然后用户说没收到钱,你一查发现成功了,尴尬不?
正确的做法是异步退款。用户申请后,系统生成退款单,状态是"处理中",然后后台异步去调支付接口。支付结果通过回调通知更新状态。这样就算网络出问题,也可以靠定时任务重试。
最后说两句
退款这事儿,看起来简单,做起来全是细节。我刚入行的时候,以为退款就是update一下订单状态,后来才发现水太深了。
taocarts的代购系统在这块做得就很完善,人家光退款相关的状态就有十几种,各种边界情况都考虑到了。这就是成熟系统和小作坊的区别。
今天就聊到这儿。你们做系统的时候有没有遇到过什么奇葩的退款需求?评论区聊聊。