首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >退货、换货和补发混在一起怎么不乱:电商售后工单的逆向状态机与并发控制

退货、换货和补发混在一起怎么不乱:电商售后工单的逆向状态机与并发控制

原创
作者头像
用户5598620
发布于 2026-09-18 14:00:25
发布于 2026-09-18 14:00:25
2150
举报

导读:下单是一条直线,售后却是一团分叉:同一个订单可能同时申请退货、换货、漏发补发,客服、仓库、财务各改各的状态,最后出现"既退款又换货""货退回来了没人收货""重复打了两笔退款"。问题不在人粗心,而在售后流程没有一套能防并发的逆向状态机。本文给一套可直接落地的设计:售后工单状态机、退货/换货/补发三条分支互斥、物流与质检驱动流转、超时自动推进、关键动作幂等,做到一单一条可追溯主线、状态不打架、钱货不重复。

一、售后为什么比下单更容易出乱子

正向交易是"创建订单→支付→发货→签收"的单链路,状态基本单调向前。售后是逆向流程,有三个天然的复杂度:

  • 多分支:退货退款、仅退款、换货、漏发补发、破损补偿,可能针对整单,也可能只针对其中一个商品;
  • 多方异步:客服受理、买家寄回、仓库收货质检、财务退款、二次发货,分属不同人和系统,谁先谁后不确定;
  • 允许反复:买家可以撤销、补充凭证、修改申请类型,仓库可以驳回要求重寄,状态会来回走。

如果用一个 status 字符串字段加一堆 if 硬堆,并发下必然出现状态覆盖和重复动作。正确做法是把售后建模成"一张工单 + 若干条明确的状态流转规则 + 关键终态动作幂等"。

二、售后工单的状态机怎么设计

一张售后单聚合一次售后诉求,明细行对应具体商品和数量。主状态围绕"货和钱有没有回来、出去"推进:

待受理 pending → 待买家寄回 waiting_return → 待仓库收货 inspecting 质检通过 → 待处理 after_sales(退款 / 换货出库 / 补发)→ 已完成 closed 任意环节驳回 → 待买家补充 need_info(可回到上一环节) 买家撤销 / 客服驳回关闭 → canceled 换货、补发这类需要二次发货的,挂一个子流程:待出库 → 已发货 → 已签收

关键原则:退款、换货出库、补发出库这些"产生钱或货"的动作,只能在特定状态下由系统触发一次,客服手工改状态不能直接跳过质检。

代码语言:sql
复制
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 商品漏发补发),但同一商品在同一时间只能有一条未结束的售后单,否则会出现重复退款。用两层防护:

  • 受理层:对"订单 + 商品 + 未结束状态"加唯一约束或占用记录,重复申请直接幂等返回已有工单;
  • 流转层:用乐观锁版本号保证两个客服(或客服与定时任务)不会同时把状态改花。
代码语言:python
复制
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 row

FOR UPDATE 行锁配合状态白名单,保证任何一次跳转都合法且可串行化;非法跳转(比如还没收货就退款)直接抛异常。

四、退款和二次发货怎么保证只发生一次

状态正确还不够,"打钱""发货"这类终态动作必须自身幂等,不能因为消息重投、用户重复点击就执行两次。做法是动作前做条件占用,配合唯一流水。

代码语言:python
复制
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 挡掉。

五、买家不寄回、仓库不处理,状态怎么自动往前走

售后最怕卡在某个环节没人管。用定时任务扫描超时工单做自动流转,规则要可解释、可配置:

  • 受理后 N 天买家未填物流:发提醒,再超时自动关闭并释放占用;
  • 仓库签收后 M 小时未质检:升级提醒到主管;
  • 质检通过后退款/换货超期:触发告警,避免钱货长期挂起。
代码语言:python
复制
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 入口,状态白名单和乐观锁对双方同时生效,不会因为半夜定时任务跑着、客服白天也在操作而冲突。

踩坑清单

  • 一个 status 字段加 if 硬堆:分支一多就漏判,必须用状态白名单显式声明可流转关系。
  • 退款只靠"当前状态判断":高并发或消息重投会重复打款,要用条件更新做动作占位。
  • 同商品允许多条未结束售后:重复退款的根源,受理时就要按订单加商品占用。
  • 客服能直接改成"已退款":跳过质检和收货会造成钱货两失,终态动作只能由系统在指定状态触发。
  • 没有超时任务:工单长期挂起既占库存又拖纠纷,关键环节都要有自动提醒和流转。
  • 撤销不释放占用:买家取消售后后,库存、已使用的抵扣券、商品可售后次数要一并回滚。

工程落地建议

售后系统建议与正向交易解耦:订单域只提供订单、商品、支付的只读视图和退款入口,售后域维护自己的工单状态机与流转日志,所有状态跳转落一条不可改的流水(谁、在什么时间、从什么状态到什么状态、触发来源),便于客诉追溯和对账。退款、出库等跨域动作用"本地占位 + 异步消息 + 幂等消费"对接,配合对账任务每日比对售后单与支付、仓储流水,把极少数异常单捞出来人工处理。

上线复盘清单

  • 售后状态与可流转关系用白名单显式声明,非法跳转有告警
  • 同一订单同一商品同时只有一条未结束售后单
  • 状态更新走行锁或乐观锁,并发不覆盖
  • 退款、换货出库、补发出库都有条件占位,重复触发幂等
  • 跨域动作为售后单号建唯一流水,超时重试不重复
  • 买家寄回、仓库质检、退款各环节有超时自动提醒与流转
  • 每次状态变更落流水,支持售后单与支付、仓储对账

常见问题

Q:同一个订单既退货又换货,售后状态怎么管才不冲突?

A:按售后诉求建工单、按商品行处理,允许不同商品并行,但同一商品同一时间只保留一条未结束工单;各工单走各自状态机,退款和换货出库分别用幂等占位,互不覆盖。

Q:售后退款怎么保证不会因为重试打两笔钱?

A:退款前用条件更新把工单的退款标记从 0 抢占为 1,只有抢占成功才调用支付通道,并以售后单号作为退款流水幂等键,任何重复请求都直接返回已退款,不再二次打款。

Q:买家申请后一直不把货寄回来,系统怎么办?

A:由定时任务扫描待寄回环节的超时工单,先提醒、再超时自动关闭并释放库存与已占用的抵扣权益;已填写物流的则进入收货质检流程,自动任务与人工操作共用同一套加锁流转入口。

结语

以上为电商售后逆向流程的一套状态机与并发控制实践,具体状态划分、超时时效和退款规则请按自身品类与平台政策调整。抓住"状态白名单管流转、条件占位管钱货、流水对账兜底"三点,售后这团分叉的流程就能像下单一样可控。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、售后为什么比下单更容易出乱子
  • 二、售后工单的状态机怎么设计
  • 三、退货、换货、补发并行时怎么互斥、不重复
  • 四、退款和二次发货怎么保证只发生一次
  • 五、买家不寄回、仓库不处理,状态怎么自动往前走
  • 踩坑清单
  • 工程落地建议
  • 上线复盘清单
  • 常见问题
    • Q:同一个订单既退货又换货,售后状态怎么管才不冲突?
    • Q:售后退款怎么保证不会因为重试打两笔钱?
    • Q:买家申请后一直不把货寄回来,系统怎么办?
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档