刚接手 teanary 售后系统升级这件事的时候,我第一反应是:这不就是一个把退款按钮做得更显眼、把客服话术统一一下的小项目吗。真正开始梳理需求后才发现,这是我在电商售后领域碰到过的最典型的“既要又要”项目,既要让用户觉得售后门槛低、处理透明、心里踏实,又要把底层从一堆零散的状态if-else里捞出来,变成后续加任何新业务都不用动筋骨的架构。这篇内容把整个升级过程、设计取舍、踩坑记录都整理出来,给同样在跟售后、订单、客服系统较劲的同学一个可参考的样本。
1. 从一个真实的售后工单说起:为什么要动这套系统
1.1 用户为什么觉得售后像“黑洞”
在做用户调研和后台日志分析的时候,我们发现了一个很有意思的现象:大部分售后投诉并不是用户对“要不要退款”有异议,而是对“我的申请到底怎么样了”完全没底。
用户提交一个售后申请后,后台确实生成了工单,但在用户眼里那就是一脚踩进了空气里,点进去只有“审核中”三个字,接下来一两天没人说话,客服群里催一句,得到的回复往往是“这边帮您催促一下”。这种状态丢的不只是时间,是用户对整个品牌的信任。
我们把用户反馈做了一次归类,大概有这么几类集中问题:
- 申请提交后完全没有反馈时间预期,用户隔几个小时刷一次,越刷越焦虑
- 客服答复前后口径不一,有的说可以退,有的说只能换,用户被踢皮球
- 售后到了哪个环节、谁在处理、还要多久,用户完全看不到
- 退款操作完成后没有主动通知,用户还要自己反复去查账单
所以“让用户安心”这句话翻译成产品语言,就是三件事:过程可见、时间可预期、关键节点有主动触达。
1.2 内部为什么觉得改不动
如果说用户侧的问题是体验层面的,那技术侧的痛点就更加让人头疼。当时的售后模块是过去两年里由一个又一个临时需求堆出来的,业务上每加一个“仅退款”“退货退款”“换货”“维修”类型,就是在主流程里加一层分支判断。
我印象最深的一段代码,是售后状态更新逻辑里嵌了十几层if-else,每个售后类型都有自己的状态枚举,互相之间还不通用。更可怕的是,状态流转逻辑散落在订单模块、客服工作台、App前端、管理后台里,经常出现同一个流程在四个地方有四份略微不同的实现。
结果就是:
- 加一个新售后类型,后台改一遍、小程序改一遍、App改一遍,联调一周起步
- 任何售后规则调整,都要提心吊胆,因为不知道哪些服务的逻辑会受影响
- 售后处理对客服个人的经验依赖过高,没有统一的标准执行框架
- 数据统计口径混乱,不同模块统计出来的退款率对不上
这已经不是优化体验的问题,而是底层结构撑不住业务发展的问题。如果不做系统级重构,后面每加一个业务渠道、每扩展一种售后类型,都是在给系统埋雷。
1.3 这次升级要交付的三个目标
基于上面两方面的现状,我们给这次售后系统升级定了一组非常明确的目标,后续所有设计和开发都围绕这三项目标来做验收:
- 用户侧:用户可以随时看到售后进度,知道自己的申请处在哪个环节、预计多久有结果,所有关键节点都有站内信或模板消息通知
- 业务侧:售后规则可以被配置而不是被写死,客服处理有标准工作台与SLA提醒,超时工单能自动升级
- 技术侧:售后系统是一个独立、可扩展的领域服务,新售后类型能通过注册配置接入,而不是每次都要改动核心代码
这三个目标对应到系统架构上,本质上要做的事情就是:把售后状态流转从业务代码中完全拆出来,统一收敛到一个可配置、可扩展的引擎层里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可扩展的底层架构:把售后处理抽象成状态机和事件
2.1 统一生命周期状态机:一套骨架撑起所有售后类型
在开始做领域建模的时候,我们拉出所有售后类型梳理了一遍,发现不管是仅退款、退货退款、换货还是维修,它们的生命周期其实逃不出一个大框架:申请发起、平台/商家审核、用户履约(寄回或寄送)、验货与结算、工单关闭。个别类型会多一些小节点,比如换货有“新订单创建”,维修有“维修处理中”,但宏观生命周期是一致的。
所以方案就很清晰了:抽一层公共状态机,作为所有售后类型的主干,每个售后类型只负责描述自己在主干上的哪些节点可操作、能流转到哪些分支状态、需要满足哪些流转条件。
我们把状态分成了四个层级来描述:
- 初始态:申请已提交
- 中间态:审核通过(待寄回)、验货失败(待用户确认)、退款失败(待重试)等
- 终态:退款成功、关闭、换货完成
- 异常态:用户取消、超时未寄回、争议升级
这样一个退货退款流程在状态机里就不再是一长串硬编码if-else,而是一张可以打印出来评审的状态流转表。
2.2 事件驱动引擎:让每个处理节点都能被订阅
有了统一状态机之后,下一个关键问题是:状态发生变更以后,后续的积分退回、优惠券返还、库存回补、消息通知、财务对账该怎么跟着动。
传统的写法是在状态变更代码后面一行一行往下调接口,这样做的最大坏处是,每加一个后续动作,都要改状态流转的核心代码,时间长了核心流程就被各种副作用逻辑塞满。
我们把这一层彻底改造成了事件驱动模型。售后状态发生变化的瞬间,系统只做两件事:持久化新状态,发布一条领域事件。至于谁关心这个事件,由事件的订阅方自己决定,核心状态机完全不感知。
举个例子,一笔售后单被审核通过后,状态机能做的事情只是从“待审核”迁移到“待退款”,并且发出一个“审核通过”的领域事件。而后续的退款执行、库存回补、通知用户,全部由不同的订阅器异步消费这个事件去完成。
这个过程有点像一个机场广播:广播台只负责把航班状态告诉大家,并不需要知道谁听到了会去改登机口屏幕、谁听到了会去给旅客发短信、谁听到了会去调整行李转盘,每个角色自己关注自己需要的信息就行。
2.3 规则配置化:把裁判逻辑从代码中搬出来
用户提交一个售后申请后,系统怎么判断是自动审核还是转人工,是全额退款还是需要扣减部分金额,这些裁判逻辑如果继续写在代码里,业务同学每调整一次策略,都要提需求排期发布版本,响应速度完全跟不上运营节奏。
所以我们引入了一层规则配置中心,把决策条件从代码中抽取出来,业务运营可以在后台通过可视化条件配置器修改规则,比如“订单金额低于300元且运费险有效时自动退款”“同一用户30天内售后次数超过3次进入人工审核”“生鲜类商品一律跳过寄回步骤直接退款”。
规则引擎的核心设计分成两个部分:
- 条件匹配层:售后工单的属性、用户历史行为、商品类目、订单维度等信息统一上报为上下文参数
- 动作执行层:判定命中后可以执行自动通过、转人工、自动关闭、通知指定角色等动作
当然,普通业务同学上手写复杂规则容易出问题,所以我们的配置中心做得更像填表而不是写代码,每个条件字段都有下拉选项,值域范围有限制,保存前还会做一次试算验证,避免配出一条和预期完全相反的规则。
2.4 模块边界与异步化设计
整个售后系统从功能模块上做了边界划分,每个模块保持独立演进能力:
- 售后申请服务给C端和客服工作台提供统一的工单创建入口,受理不同来源的售后诉求
- 规则引擎消费申请事件,根据配置参数做自动化决策
- 状态机引擎负责状态合法性校验和流转管理
- 事件总线负责把状态变更和节点动作广播给所有订阅方
- 结算模块负责退款、补偿、扣款指令的生成推送给支付系统
- 通知模块负责向用户端、客服端发出进度消息
这样划分之后,售后系统的核心不再是什么都干的“售后大管家”,而是一个轻量调度中心,各种能力和扩展点都以事件订阅的方式挂接在外围。团队内部让新同事去理解这套架构时,我打过一个比方:老系统像一群人挤在一个房间里,谁要说话都得喊,谁动一下都会碰到别人;改造后的系统像公司办公区,每个部门有自己的工位,跨部门协作通过邮件和会议室,而不是直接跑到别人桌上乱翻文件。
3. 升级实操:核心模块的落地过程与代码思路
3.1 项目路径:先跑通一条链路,再横向铺开
售后这种业务和别的功能不一样,不能直接停服切换,也不能搞两年的大爆炸重写。我们采用渐进式迁移,第一步选择业务量最大的“退货退款(寄回后退款)”作为首个接入新框架的售后类型,先把这条链路完整打通,再扩展其他类型。
整个实施路径被切成了四个阶段:
- 数据与领域模型梳理:盘点所有售后单状态字段,梳理历史脏数据,设计新模型映射关系
- 单链路验证:先用退货退款流程完成新状态机加事件驱动的端到端测试
- 类型逐步扩展:把仅退款、换货、维修逐个注册到新框架
- 旧入口下线:老逻辑停止接新工单,存量处理完后归档
这种策略的好处是风险可控,第一版上线只影响退货退款这一条链路,即使出现问题,影响面也是有限的,不会出现整个售后模块崩掉连客服系统都用不了的状况。
3.2 领域数据模型与状态机注册
新模型的核心表设计其实不复杂,但每一张表都承载了特定职责。以售后主单表为例,它不再存一堆散乱的状态位,而是用状态机代码加当前状态来标记位置。字段示意如下:
sql复制CREATE TABLE `after_sale_order` (
`id` bigint NOT NULL COMMENT '主键',
`order_no` varchar(64) NOT NULL COMMENT '售后单号',
`user_id` bigint NOT NULL COMMENT '用户ID',
`order_id` bigint NOT NULL COMMENT '原订单ID',
`type` tinyint NOT NULL COMMENT '售后类型:1仅退款 2退货退款 3换货 4维修',
`machine_code` varchar(32) NOT NULL COMMENT '使用的状态机编码',
`current_state` varchar(32) NOT NULL COMMENT '当前状态节点编码',
`expected_end_time` datetime DEFAULT NULL COMMENT '预计办结时间',
`version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id_state` (`user_id`, `current_state`),
KEY `idx_order_id` (`order_id`)
) COMMENT='售后主单表';
状态机的定义在应用层用配置对象来声明,以我们当前所用的技术栈为例,配置大致长这样:
java复制@Configuration
public class AfterSaleStateMachineConfig {
@Bean
public StateMachine<AfterSaleState, AfterSaleEvent> returnRefundMachine() {
StateMachine<AfterSaleState, AfterSaleEvent> machine = new StateMachine<>("RETURN_REFUND");
machine.transition()
.from(AfterSaleState.APPLIED)
.on(AfterSaleEvent.SUBMIT)
.to(AfterSaleState.PENDING_REVIEW);
machine.transition()
.from(AfterSaleState.PENDING_REVIEW)
.on(AfterSaleEvent.AUTO_APPROVE)
.to(AfterSaleState.WAIT_USER_RETURN);
machine.transition()
.from(AfterSaleState.PENDING_REVIEW)
.on(AfterSaleEvent.MANUAL_APPROVE)
.to(AfterSaleState.WAIT_USER_RETURN);
machine.transition()
.from(AfterSaleState.PENDING_REVIEW)
.on(AfterSaleEvent.REJECT)
.to(AfterSaleState.CLOSED);
machine.transition()
.from(AfterSaleState.WAIT_USER_RETURN)
.on(AfterSaleEvent.SUBMIT_TRACKING_NO)
.to(AfterSaleState.WAIT_CHECK);
machine.transition()
.from(AfterSaleState.WAIT_CHECK)
.on(AfterSaleEvent.CHECK_PASS)
.to(AfterSaleState.PENDING_REFUND);
machine.transition()
.from(AfterSaleState.PENDING_REFUND)
.on(AfterSaleEvent.REFUND_SUCCESS)
.to(AfterSaleState.SUCCESS);
machine.transition()
.from(AfterSaleState.PENDING_REFUND)
.on(AfterSaleEvent.REFUND_FAIL)
.to(AfterSaleState.PENDING_REFUND_RETRY);
return machine;
}
}
这套定义的可读性比原来的if-else好太多,状态从哪来到哪去、由什么事件触发一目了然。状态机的核心价值在于严格约束:所有状态迁移必须经过引擎校验,引擎在流转前会检查当前状态是否真的允许该事件发生,如果业务方传了一个非法的操作意图,直接抛异常而不是静默跳过。
3.3 自动退款链路的异步编排
规则引擎判定一笔售后单可以自动退款后,状态机把状态推到“待退款”,同时发出退款事件。退款订阅器拿到事件后先做幂等校验,然后调用支付网关执行退款。
这个过程的伪代码表达如下:
python复制def on_after_sale_review_passed(event):
after_sale_id = event.after_sale_id
# 幂等检查,避免重复退款
if refund_service.exists(after_sale_id):
log_warning(f"after_sale {after_sale_id} already refunded")
return
refund_amount = calc_refund_amount(after_sale_id)
# 执行退款
try:
refund_service.refund(after_sale_id, refund_amount)
state_machine.fire(after_sale_id, AfterSaleEvent.REFUND_SUCCESS)
notify_user(after_sale_id, "退款已到账")
except RefundException as ex:
state_machine.fire(after_sale_id, AfterSaleEvent.REFUND_FAIL)
alert_operators(after_sale_id, str(ex))
这段代码里最关键的不是退款本身,而是幂等。在分布式系统里,事件可能会被重复投递,订阅方消费完还没来得及提交offset就重启了,下一次又会收到同一事件。如果不做幂等,用户就可能收到两笔退款,产生资损事故。
我们的做法是在退款流水表上建唯一索引,以售后单号作为业务唯一键。重复事件进来后,数据库会直接拒绝重复插入,程序捕获到唯一键冲突就返回成功,不再继续往下执行。
3.4 用户时间线回看的实现思路
进度可见性的核心是让用户可以像看物流轨迹一样看到完整的售后时间线。这个功能如果没有底层事件机制支撑,实现起来会很别扭,可能要单独建一张日志表,然后被动地在各业务代码里手动记录。
改造成事件驱动之后,时间线变得非常自然。所有关键事件在发布时都带有清晰的业务语义描述,用户端只需要读事件流,按时间顺序渲染即可。事件表结构大致如下:
sql复制CREATE TABLE `after_sale_event_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`after_sale_id` bigint NOT NULL,
`event_type` varchar(64) NOT NULL COMMENT '事件类型',
`event_body` json NOT NULL COMMENT '事件负载',
`operator_name` varchar(64) DEFAULT NULL COMMENT '操作人,系统操作则为空',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_after_sale_id_created_at` (`after_sale_id`, `created_at`)
) COMMENT='售后单事件流水表';
用户端App展示售后进度时,只需要拉取这张表的数据,不需要请求超过两个后台接口。而且因为事件是系统在各个节点自动记录的,开发者不用担心漏记或重复记,只要状态机和订阅器执行正确,时间线信息一定完整。
4. 让用户“安心”的产品细节:体验层的几个关键动作
4.1 让进度可见:状态与预计办结时间
如果只是把“审核中”改成“平台正在审核您的申请”,本质上没有解决用户焦虑。真正让用户安心的是给一个确定的时间锚点。我们在每个售后单页面顶部都标明了“预计办结时间”。这个时间不是拍脑袋填的,而是根据工单类型、当前排队量、客服负载动态计算出来的,线上跑下来的准确性还不错。
前端文案也做了很多轮调整,从冷冰冰的状态词转换成有温度的叙述。比如:
- 待商家审核 → 您的售后申请已提交,商家将在24小时内处理
- 检测通过 → 商品已验收完成,退款预计1-3个工作日到账
- 退款处理中 → 财务正在为您安排退款,请留意到账通知
这些文案的共同点就是明确告诉用户两件事:当前是什么状态、下一步会发生什么。
4.2 超时自动升级与兜底人工
在传统模式下,一个售后单超过48小时没人处理,系统既不会报警也不会通知任何人,用户只能自己找客服催。升级后,超时成了一个一等公民事件。系统内维护了一套SLA定时任务,会定期扫描所有处于非终态的售后单,一旦发现超过时限未处理,就自动向责任客服的工作台推送提醒,超过第二层时限后自动升级到组长,再超过就进入异常工单池。
超时工单的状态本身也会反馈给用户,用户在页面会看到系统自动提示“您的工单已超过预计处理时间,我们已为您升级处理,请耐心等待”。从数据上看,这个动作带来的用户满意度提升非常明显,因为用户感受到了系统在主动处理问题。
4.3 消息留痕与承诺管理
售后系统升级前,客服和用户的沟通往往通过IM工具进行。用户抱怨过一个问题:在IM里客服答应退款了,用户等了两天没看到,再来问的时候换个客服,对方说没有查到相关承诺记录,用户瞬间就炸了。
升级后,所有客服对工单的操作、审核意见、备注信息全部绑定售后单留存。客服如果在IM聊天框里做出承诺,系统会引导客服把承诺内容填写到工单备注中,并附带承诺的执行时限。到了时限系统自动触发执行检查,如果没执行就会在客服工作台亮红牌提醒。
这套机制解决的核心问题是把售后从“对话驱动”转变成“工单驱动”,用户不再依赖记住某个客服说了什么,而是所有承诺都被系统化地追踪。
4.4 主动触达:从用户提问到系统汇报
旧系统是用户不问、系统不动。升级后我们设计了一套主动触达机制,在关键节点自动推送消息给用户:
- 申请提交成功时推送回执,告知处理时效
- 商家审核通过后,第一时间通知用户“可以寄回商品”
- 仓库验收完成、退款进入执行时,推送进度
- 退款到账后主动发送到账引导,让用户核对金额
在运营设计上,我们把主动触达分成“事务性通知”和“关怀性通知”两类,事务性通知必须及时,关怀性通知则可以控制频次。这套机制的出发点是,售后不是用户喜欢做的事,我们至少要让整个过程像点外卖一样,每一步都能感知,每一刻都知道接下来会发生什么。
5. 上线后的真实问题与排查笔记
5.1 状态机被跳过的非法流转
第一个遇到的问题是线上出现了“已关闭的售后单又收到了退款成功事件”的情况。排查下来发现,退款订阅器没有守住状态机的校验边界,在收到事件后直接执行了退款,只做了自身表结构的幂等,但没有检查当前售后单是不是处于可退款状态。
这个问题的根源在于事件订阅方不能完全信任事件本身,还需要在消费时校验当前业务状态。我们后来在所有订阅器的入口统一加了一层前置校验,确保只有售后单状态机处于允许执行动作的状态节点时,才允许后续逻辑执行。这就像门禁系统,不是谁敲门都应该开门,还需要确认门外的人是谁、现在是不是会客时间。
5.2 重复退款与重复通知:幂等第一
上线第二周,有一个售后单被重复处理了,原因不是事件重复投递,而是人工退款和自动退款链路同时触发了。客服看到某个售后单已经手动点了退款按钮,但当时自动退款任务也在处理同一笔单子,两边都过了幂等检查,最终支付网关收到了两笔退款指令。
这里暴露出的问题是我们把幂等范围只限制在单一执行链内,没有做到跨执行链的全局互斥。解决方法很直接:把幂等表从退款流水扩展成“售后单全局执行记录”,任何针对同一售后单的敏感操作,都必须先获取分布式锁并登记操作意图,第二个操作看到已有执行记录后直接拒绝。这类问题的排查不能只靠嘴说,要把执行日志和状态流转日志全部串起来,才能准确还原到底是哪一步出了问题。
5.3 超时提级任务的并发冲突
超时扫描任务上线后发现存在并发问题。任务调度使用了多个分片并行扫描同一批数据,分片之间没有做好互斥,导致同一张超时工单被两个分片同时提级,生成了两条升级提醒,用户收到两条几乎一样的内容。
修复方式是在扫描任务中增加了行级乐观锁,扫描器先尝试更新工单的提级标记字段,利用版本号保证只有一台机器能提级成功。这个动作在测试环境很难复现,因为生产环境的数据量和并发度远远高于测试环境,直到灰度上线后才暴露出来。后来我把所有定时任务类的代码从“先查再改”都改成“先更新条件限制行再拿数据”的写法,从源头规避了并发窗口。
5.4 存量历史售后单与新逻辑的兼容
系统上线时还有很多历史售后单停留在旧流程中,它们的当前状态不在新状态机的状态枚举里,如果直接按新逻辑处理,这些工单会变成“无状态”工单,既不能被关闭,也不能被编辑。
我们在迁移策略上做了一个妥协:历史工单和新建工单走两套展示逻辑,历史工单在用户端不再显示新样式的时间线,而是统一降级显示为“请联系客服处理”。同时客服工作台提供数据修复入口,运营可以把历史工单逐批导入到新状态机的对应状态下,导入过程中系统自动补发事件日志,确保后续追踪数据完整。
这个教训说明,升级系统不能只考虑“新单用新逻辑”,还要给历史数据设计专门的兼容通道,否则用户一打开历史订单看到完全不认识的页面,客服电话会立刻被打爆。
5.5 规则调整频率过高导致的配置漂移
规则配置化上线后,运营团队非常兴奋,头两周改了将近三十条售后规则。结果随后就出现了一个问题:同一个售后单被规则引擎判了几次,每次结果不一样,因为运营在改配置的过程中没有版本管理,旧消息还在队列里消费,用的却是新版本规则。
这个问题的本质是规则引擎的规则集在多版本并存时需要做一致性快照。我们把规则发布改为版本化处理:每次发布新规则都会生成一个版本号,售后单在处理开始时指定使用哪个版本的规则集,整个处理过程不随规则热更新而改变。规则版本上线后支持秒级回滚,运营可以放心试验,即使新策略有问题,回滚到上一版只需要点击一个按钮。
6. 可扩展性验证与后续演进空间
6.1 新售后类型接入的“配置”体验
升级完成后,我们专门做了一次“换货流程”的接入演练来验证可扩展性。整个接入过程只做了三件事:注册新的状态机配置、定义换货特有的领域事件、订阅相关通知动作。
核心代码几乎零改动,后端状态机引擎、事件总线、超时引擎、通知模块全部复用。原来如果要把换货流程加到旧系统里,预计要改三十多个文件,打通联调至少两周,这次只花了两天就完成了端到端测试。而且因为整体的兜底机制已经下沉到公共层,新增类型并没有额外增加客服培训成本,客服工作台面对不同售后流程时,操作界面是一致的,只是可点击的按钮不同。
6.2 多端多渠道接入的产品化方向
售后系统重构后,很自然地解锁了一个新能力:把售后API开放给更多第三方渠道使用。商家不再只通过自家小程序发起售后,还希望通过开放接口承接其他平台或线下门店的售后单。
原来这个想法在旧架构下基本不可行,因为状态逻辑是和特定端绑定的。而现在开放平台只需要对接事件总线,不同来源的售后单都能在同一个状态机框架内流转,数据全部沉淀在统一的事件流中,运营报表可以自动覆盖所有渠道。
6.3 售后健康度大盘与持续度量的指标
系统稳定运行一段时间后,团队搭了一个售后健康度大盘。里面最核心的几个指标是:
- 平均处理时长:从用户提交到工单关闭的平均周期
- 超时率:超过预计办结时间的工单比例
- 首响时长:用户提交申请后到获得第一次有效反馈的时间
- 用户主动催单率:用户关注度异常的信号
- 自动处理占比:规则引擎自动完成的比例
值得强调的是,这些指标必须全链路追踪到同一个售后单维度,如果用户侧时间线和内部状态流转对不上号,那么看任何指标都是盲人摸象。
这个升级做下来,我个人最深的体会是:售后系统的“用户安心”和“架构可扩展”其实是一件事的两面。没有事件驱动和状态机支撑,产品经理想做进度可见、超时提醒都是在空中盖楼,每一步都要硬编码,改一处崩三处;而有了清晰的领域模型和统一流转框架之后,很多用户侧的体验升级只是配置和订阅的事,变得又快又稳。
最后分享一个小建议,给同样要搞售后或工单系统升级的朋友:再复杂的系统也别贪多求全,先从每天触发量最大、用户体感最强的一条链路做端到端的重构试点,跑顺了再铺开。另外,状态变更代码里永远不要夹带发短信、退库存这类副作用逻辑,把这些动作全部交到事件订阅器里,后面你会感谢当初这个决定的。
