导读:下单是一条直线,售后却是一团分叉:同一个订单可能同时申请退货、换货、漏发补发,客服、仓库、财务各改各的状态,最后出现"既退款又换货""货退回来了没人收货""重复打了两笔退款"。问题不在人粗心,而在售后流程没有一套能防并发的逆向状态机。本文给一套可直接落地的设计:售后工单状态机、退货/换货/补发三条分支互斥、物流与质检驱动流转、超时自动推进、关键动作幂等,做到一单一条可追溯主线、状态不打架、钱货不重复。
正向交易是"创建订单→支付→发货→签收"的单链路,状态基本单调向前。售后是逆向流程,有三个天然的复杂度:
如果用一个 status 字符串字段加一堆 if 硬堆,并发下必然出现状态覆盖和重复动作。正确做法是把售后建模成"一张工单 + 若干条明确的状态流转规则 + 关键终态动作幂等"。
一张售后单聚合一次售后诉求,明细行对应具体商品和数量。主状态围绕"货和钱有没有回来、出去"推进:
待受理 pending → 待买家寄回 waiting_return → 待仓库收货 inspecting 质检通过 → 待处理 after_sales(退款 / 换货出库 / 补发)→ 已完成 closed 任意环节驳回 → 待买家补充 need_info(可回到上一环节) 买家撤销 / 客服驳回关闭 → canceled 换货、补发这类需要二次发货的,挂一个子流程:待出库 → 已发货 → 已签收
关键原则:退款、换货出库、补发出库这些"产生钱或货"的动作,只能在特定状态下由系统触发一次,客服手工改状态不能直接跳过质检。
CREATE TABLE after_sales_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
as_no VARCHAR(32) NOT NULL, -- 售后单号
order_no VARCHAR(32) NOT NULL,
type VARCHAR(16) NOT NULL, -- refund/return_refund/exchange/resend
status VARCHAR(20) NOT NULL DEFAULT 'pending',
version INT NOT NULL DEFAULT 0, -- 乐观锁版本号
refund_done TINYINT NOT NULL DEFAULT 0, -- 退款是否已执行(终态动作标记)
reship_done TINYINT NOT NULL DEFAULT 0, -- 换货/补发是否已出库
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_as_no (as_no),
KEY idx_order (order_no)
);同一订单允许多种售后并存(例如 A 商品退货、B 商品漏发补发),但同一商品在同一时间只能有一条未结束的售后单,否则会出现重复退款。用两层防护:
ALLOWED = {
"pending": {"waiting_return", "need_info", "closed", "after_sales"},
"waiting_return": {"inspecting", "need_info", "canceled"},
"inspecting": {"after_sales", "need_info", "waiting_return"},
"after_sales": {"closed", "inspecting"},
"need_info": {"waiting_return", "inspecting", "closed"},
}
def transit(conn, as_id, target, expected_refund=None):
# 乐观锁:基于当前版本号更新,并发下只有一方成功
row = conn.execute("SELECT status, version, refund_done, reship_done "
"FROM after_sales_order WHERE id=%s FOR UPDATE", as_id)
if target not in ALLOWED[row.status]:
raise IllegalTransition(f"{row.status} -> {target} 不允许")
conn.execute("UPDATE after_sales_order SET status=%s, version=version+1 "
"WHERE id=%s AND version=%s", target, as_id, row.version)
return rowFOR UPDATE 行锁配合状态白名单,保证任何一次跳转都合法且可串行化;非法跳转(比如还没收货就退款)直接抛异常。
状态正确还不够,"打钱""发货"这类终态动作必须自身幂等,不能因为消息重投、用户重复点击就执行两次。做法是动作前做条件占用,配合唯一流水。
def do_refund(conn, as_id):
affected = conn.execute(
"UPDATE after_sales_order SET refund_done=1, version=version+1 "
"WHERE id=%s AND status='after_sales' AND refund_done=0", as_id)
if affected == 0:
return "refund_already_done" # 已退过,幂等返回,不再调用支付通道
# 仅当抢占成功才真正发起退款,退款流水以售后单号为幂等键
pay_gateway.refund(out_request_no="RF" + as_id, amount=calc_amount(as_id))
conn.execute("UPDATE after_sales_order SET status='closed' WHERE id=%s", as_id)换货、补发的出库同理,用 reship_done 占位,并在出库单表对售后单号建唯一索引。即使支付通道超时重试、消息队列重复投递,第二次也会被 WHERE refund_done=0 挡掉。
售后最怕卡在某个环节没人管。用定时任务扫描超时工单做自动流转,规则要可解释、可配置:
def auto_advance(now):
for o in query("SELECT * FROM after_sales_order WHERE status='waiting_return' "
"AND updated_at < %s", now - RETURN_DAYS * 86400):
if has_return_tracking(o): # 已填物流则不动,交给收货环节
continue
transit_safe(o["id"], "canceled", reason="买家超时未寄回")自动任务和人工操作共用同一个 transit 入口,状态白名单和乐观锁对双方同时生效,不会因为半夜定时任务跑着、客服白天也在操作而冲突。
售后系统建议与正向交易解耦:订单域只提供订单、商品、支付的只读视图和退款入口,售后域维护自己的工单状态机与流转日志,所有状态跳转落一条不可改的流水(谁、在什么时间、从什么状态到什么状态、触发来源),便于客诉追溯和对账。退款、出库等跨域动作用"本地占位 + 异步消息 + 幂等消费"对接,配合对账任务每日比对售后单与支付、仓储流水,把极少数异常单捞出来人工处理。
A:按售后诉求建工单、按商品行处理,允许不同商品并行,但同一商品同一时间只保留一条未结束工单;各工单走各自状态机,退款和换货出库分别用幂等占位,互不覆盖。
A:退款前用条件更新把工单的退款标记从 0 抢占为 1,只有抢占成功才调用支付通道,并以售后单号作为退款流水幂等键,任何重复请求都直接返回已退款,不再二次打款。
A:由定时任务扫描待寄回环节的超时工单,先提醒、再超时自动关闭并释放库存与已占用的抵扣权益;已填写物流的则进入收货质检流程,自动任务与人工操作共用同一套加锁流转入口。
以上为电商售后逆向流程的一套状态机与并发控制实践,具体状态划分、超时时效和退款规则请按自身品类与平台政策调整。抓住"状态白名单管流转、条件占位管钱货、流水对账兜底"三点,售后这团分叉的流程就能像下单一样可控。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。