电商系统里,售后退换货是最容易出纠纷、也最容易埋坑的模块。
LikeShop 单商户 Java 版原生已经提供了基础的退款能力,但在实际业务落地时,很多客户会有更细化的诉求:区分「仅退款」与「退货退款」、支持换货、多级审核、用户寄回物流跟踪、仓库收货核验、部分商品退换、运费承担规则等。如果直接在原有订单逻辑里硬改,很容易打乱原有的订单状态机,导致资金、库存不同步。
这类需求属于典型的业务扩展型二开,最佳实践是独立售后单体系 + 状态机驱动 + 事件联动原有订单能力,不推翻原生订单逻辑,以最小侵入成本实现完整的退换货流程。
本文基于 LikeShop 单商户 Java 版的技术底座,从架构设计、表结构改造、核心流程实现、上下游联动、平滑升级五个维度,整理一套可落地的售后二开方案。
一、二开背景与核心目标
1.1 原生售后能力的局限
原生版本以订单维度的退款为主,能够满足基础的全额退款场景,但在复杂退换货场景下存在短板:
- 流程颗粒度粗:未区分仅退款、退货退款、换货三种核心售后类型;
- 状态节点少:缺少商家审核、用户寄回、仓库收货、退款中这些中间状态;
- 部分退换支持弱:一个订单多件商品时,无法针对单个商品发起售后;
- 物流与收货缺失:退货物流跟踪、仓库收货核验环节能力不足;
- 审核规则单一:缺少超时自动同意、商家驳回可申诉等灵活配置。
1.2 二开核心目标
- 售后类型全覆盖:支持仅退款、退货退款、换货三种主流售后类型;
- 完整状态机:从申请、审核、退货、收货、退款/换货到完结,全链路状态可控,非法跳转拦截;
- 部分退换支持:支持按商品维度发起售后,一个订单可多次发起售后;
- 最小侵入原则:独立售后单体系,复用原生订单、库存、支付退款能力,不破坏原有正向流程;
- 可配置化:审核超时时间、运费承担规则、驳回是否可重提等可配置,适配不同行业客户。
二、核心设计思路
2.1 三种售后类型
| 类型 | 流程特点 | 适用场景 |
|---|---|---|
| 仅退款 | 不用退货,审核通过直接退款 | 商品破损、发错货、差价补偿 |
| 退货退款 | 用户寄回商品,仓库收货后退款 | 7天无理由、质量问题退货 |
| 换货 | 用户寄回旧品,仓库重新发新品 | 尺码不合适、发错型号、质量问题换货 |
2.2 售后状态机
状态机是售后模块的灵魂,所有操作前置校验状态,杜绝非法跳转。以退货退款为例,完整流转如下:
用户提交申请 → 待商家审核
↓
┌─ 商家驳回 → 已驳回 → 用户可重新提交
│
└─ 商家同意 → 待用户退货
↓
用户填写物流 → 待商家收货
↓
仓库核验收货 → 待退款
↓
发起原路退款 → 退款中
↓
退款成功 → 已完成
补充异常分支:
- 审核超时未处理 → 系统自动同意;
- 用户超时未退货 → 自动关闭售后;
- 收货核验不通过 → 驳回退款,状态回退待用户确认。
2.3 订单与售后的关系
- 一对多关系:一个正向订单可发起多笔售后单,支持部分商品、部分数量退换;
- 金额独立计算:每笔售后单单独计算退款金额、运费、优惠分摊;
- 状态解耦:售后单有独立状态机,不直接修改原订单状态,仅在终态时同步订单售后标记。
三、数据库设计
采用「新增表为主、扩展字段为辅」的策略,尽量不修改原生表结构,降低升级冲突。
3.1 售后主表 ls_after_sale
承载售后单核心信息,每笔申请一条记录。
CREATE TABLE ls_after_sale (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
after_sale_sn VARCHAR(32) NOT NULL COMMENT '售后单号',
order_id BIGINT NOT NULL COMMENT '原订单ID',
order_sn VARCHAR(32) NOT NULL COMMENT '原订单号',
user_id BIGINT NOT NULL,
type TINYINT NOT NULL COMMENT '售后类型:1仅退款 2退货退款 3换货',
reason VARCHAR(128) NOT NULL COMMENT '售后原因',
description VARCHAR(500) COMMENT '问题描述',
images VARCHAR(500) COMMENT '凭证图片,逗号分隔',
refund_amount DECIMAL(10,2) NOT NULL COMMENT '退款金额',
refund_freight DECIMAL(10,2) DEFAULT 0 COMMENT '退运费金额',
freight_bearer TINYINT COMMENT '运费承担方:1买家 2商家',
status TINYINT NOT NULL COMMENT '售后状态',
reject_reason VARCHAR(255) COMMENT '驳回原因',
apply_time DATETIME NOT NULL,
audit_time DATETIME COMMENT '审核时间',
finish_time DATETIME COMMENT '完成时间',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_after_sale_sn(after_sale_sn),
INDEX idx_order_id(order_id),
INDEX idx_user_id(user_id),
INDEX idx_status(status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后主表';
3.2 售后商品明细表 ls_after_sale_item
记录本次售后涉及的具体商品与数量,支持部分退换。
CREATE TABLE ls_after_sale_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
after_sale_id BIGINT NOT NULL,
order_item_id BIGINT NOT NULL COMMENT '原订单商品明细ID',
goods_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
goods_name VARCHAR(255) NOT NULL,
sku_specs VARCHAR(128) COMMENT '规格信息',
goods_image VARCHAR(255),
quantity INT NOT NULL COMMENT '退换数量',
unit_price DECIMAL(10,2) NOT NULL COMMENT '单价',
total_amount DECIMAL(10,2) NOT NULL COMMENT '该商品退款金额',
INDEX idx_after_sale_id(after_sale_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后商品明细表';
3.3 售后操作日志表 ls_after_sale_log
全流程留痕,每一次状态变更都记录,纠纷时可追溯。
CREATE TABLE ls_after_sale_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
after_sale_id BIGINT NOT NULL,
from_status TINYINT NOT NULL,
to_status TINYINT NOT NULL,
action VARCHAR(32) NOT NULL COMMENT '操作动作:apply/approve/reject/deliver/receive/refund',
operator_type TINYINT NOT NULL COMMENT '1用户 2商家 3系统',
operator_id BIGINT,
remark VARCHAR(255),
create_time DATETIME NOT NULL,
INDEX idx_after_sale_id(after_sale_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后操作日志表';
3.4 退货物流表 ls_after_sale_express
退货退款与换货场景下,记录用户寄回的物流信息。
CREATE TABLE ls_after_sale_express (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
after_sale_id BIGINT NOT NULL UNIQUE,
express_company VARCHAR(32),
express_no VARCHAR(32),
deliver_time DATETIME COMMENT '用户寄出时间',
receive_time DATETIME COMMENT '商家收货时间',
receive_remark VARCHAR(255) COMMENT '收货备注',
INDEX idx_after_sale_id(after_sale_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货物流表';
3.5 原订单表扩展
原生订单表仅增加一个标记字段,不破坏原有结构。
ALTER TABLE ls_order
ADD COLUMN after_sale_status TINYINT DEFAULT 0 COMMENT '售后状态:0无售后 1售后中 2部分售后 3全部售后'
AFTER order_status;
四、核心业务流程与代码实现
所有核心操作封装在独立的 AfterSaleService 中,通过事件驱动联动订单、库存、支付模块,不侵入原生业务代码。
4.1 用户提交售后申请
校验订单状态、商品可退数量、金额计算,生成售后单与明细。
@Service
public class AfterSaleServiceImpl implements AfterSaleService {
@Autowired
private AfterSaleMapper afterSaleMapper;
@Autowired
private AfterSaleItemMapper itemMapper;
@Autowired
private OrderService orderService;
@Override
@Transactional(rollbackFor = Exception.class)
public AfterSaleVO apply(AfterSaleApplyDTO dto, Long userId) {
// 1. 校验原订单状态:已付款、未完成或已完成且在售后期内
Order order = orderService.getById(dto.getOrderId());
if (!order.isAfterSaleAllowed()) {
throw new BizException("当前订单状态不可申请售后");
}
// 2. 校验商品可退数量,防止超额申请
List<AfterSaleItemDTO> items = dto.getItems();
checkReturnQuantity(order, items);
// 3. 计算退款金额(含优惠分摊、运费计算)
BigDecimal refundAmount = calcRefundAmount(order, items);
BigDecimal refundFreight = calcRefundFreight(order, dto);
// 4. 生成售后单号
String afterSaleSn = SnGenerator.generate("AS");
// 5. 写入售后主表
AfterSale afterSale = new AfterSale();
afterSale.setAfterSaleSn(afterSaleSn);
afterSale.setOrderId(order.getId());
afterSale.setOrderSn(order.getOrderSn());
afterSale.setUserId(userId);
afterSale.setType(dto.getType());
afterSale.setReason(dto.getReason());
afterSale.setDescription(dto.getDescription());
afterSale.setImages(dto.getImages());
afterSale.setRefundAmount(refundAmount);
afterSale.setRefundFreight(refundFreight);
afterSale.setStatus(AfterSaleStatus.PENDING_AUDIT.getCode());
afterSale.setApplyTime(new Date());
afterSaleMapper.insert(afterSale);
// 6. 写入售后商品明细
batchInsertItems(afterSale.getId(), items, order);
// 7. 记录操作日志
saveLog(afterSale.getId(), null, AfterSaleStatus.PENDING_AUDIT,
"apply", OperatorType.USER, userId, "提交售后申请");
// 8. 标记订单进入售后状态
orderService.markAfterSaleStatus(order.getId(), AfterSaleOrderStatus.PROCESSING);
// 9. 发布售后申请事件,通知商家
applicationEventPublisher.publishEvent(new AfterSaleAppliedEvent(afterSale));
return BeanCopyUtils.copy(afterSale, AfterSaleVO.class);
}
}
4.2 商家审核
同意或驳回,校验当前状态,同步触发后续流程。
@Override
@Transactional(rollbackFor = Exception.class)
public void audit(Long afterSaleId, boolean pass, String reason, Long adminId) {
AfterSale afterSale = afterSaleMapper.selectById(afterSaleId);
if (!afterSale.getStatus().equals(AfterSaleStatus.PENDING_AUDIT.getCode())) {
throw new BizException("当前状态不可审核");
}
Integer fromStatus = afterSale.getStatus();
if (pass) {
// 审核通过
if (afterSale.getType() == AfterSaleType.REFUND_ONLY) {
// 仅退款:直接进入待退款
afterSale.setStatus(AfterSaleStatus.PENDING_REFUND.getCode());
// 异步发起退款
asyncRefund(afterSale);
} else {
// 退货/换货:待用户退货
afterSale.setStatus(AfterSaleStatus.WAIT_DELIVER.getCode());
}
afterSale.setAuditTime(new Date());
} else {
// 驳回
afterSale.setStatus(AfterSaleStatus.REJECTED.getCode());
afterSale.setRejectReason(reason);
}
afterSaleMapper.updateById(afterSale);
// 记录日志
saveLog(afterSaleId, fromStatus, afterSale.getStatus(),
pass ? "approve" : "reject", OperatorType.MERCHANT, adminId, reason);
// 通知用户审核结果
notifyUserAuditResult(afterSale);
}
4.3 仓库收货确认与退款执行
用户寄回商品,仓库核验无误后,确认收货并触发退款。
@Override
@Transactional(rollbackFor = Exception.class)
public void confirmReceive(Long afterSaleId, String remark, Long adminId) {
AfterSale afterSale = afterSaleMapper.selectById(afterSaleId);
if (!afterSale.getStatus().equals(AfterSaleStatus.WAIT_RECEIVE.getCode())) {
throw new BizException("当前状态不可确认收货");
}
Integer fromStatus = afterSale.getStatus();
if (afterSale.getType() == AfterSaleType.RETURN_REFUND) {
// 退货退款:进入待退款
afterSale.setStatus(AfterSaleStatus.PENDING_REFUND.getCode());
afterSaleMapper.updateById(afterSale);
// 发起原路退款
asyncRefund(afterSale);
} else if (afterSale.getType() == AfterSaleType.EXCHANGE) {
// 换货:进入待发货,商家重新发新品
afterSale.setStatus(AfterSaleStatus.WAIT_SHIP.getCode());
afterSaleMapper.updateById(afterSale);
// 触发换货发货通知
applicationEventPublisher.publishEvent(new ExchangeShipEvent(afterSale));
}
// 回补库存(退货入库)
restockAfterReturn(afterSale);
saveLog(afterSaleId, fromStatus, afterSale.getStatus(),
"receive", OperatorType.MERCHANT, adminId, remark);
}
4.4 退款成功回调
支付通道退款成功后,更新售后单状态,同步订单售后标记。
@Override
@Transactional(rollbackFor = Exception.class)
public void onRefundSuccess(String afterSaleSn, String refundFlowNo) {
AfterSale afterSale = afterSaleMapper.selectBySn(afterSaleSn);
if (afterSale.getStatus().equals(AfterSaleStatus.REFUNDED.getCode())) {
return; // 幂等
}
Integer fromStatus = afterSale.getStatus();
afterSale.setStatus(AfterSaleStatus.REFUNDED.getCode());
afterSale.setFinishTime(new Date());
afterSale.setRefundFlowNo(refundFlowNo);
afterSaleMapper.updateById(afterSale);
// 更新订单售后状态
updateOrderAfterSaleStatus(afterSale.getOrderId());
// 记录日志
saveLog(afterSale.getId(), fromStatus, AfterSaleStatus.REFUNDED,
"refund_success", OperatorType.SYSTEM, null, "退款成功");
// 通知用户
notifyUserRefundSuccess(afterSale);
}
五、上下游模块联动
售后不是孤立模块,必须和订单、库存、支付、积分等模块联动,才能保证数据一致。
5.1 订单模块
- 售后申请成功,订单标记「售后中」;
- 所有售后单完结后,更新订单最终售后状态(部分售后/全部售后);
- 不直接修改原订单金额与支付状态,资金变动以售后单为准,避免打乱原生订单统计。
5.2 库存模块
- 仅退款不涉及库存变动;
- 退货退款、换货收货后,商品无损坏则回补可售库存;
- 损坏商品计入残次品库存,不回补可售库存;
- 换货发货时扣减新商品库存,走正常出库逻辑。
5.3 支付退款模块
复用 LikeShop 原生的支付退款能力,售后模块只发起退款请求,不重复对接支付通道。退款异步回调通过事件通知售后模块更新状态,解耦两个模块。
5.4 积分与优惠券
- 退款时按比例回退使用的积分与优惠券;
- 积分退回用户账户,优惠券退回至「已使用」状态不可二次使用,防止薅羊毛;
- 金额计算与优惠分摊逻辑与下单时保持一致,避免精度误差。
5.5 定时任务兜底
- 商家审核超时:超过配置时长未处理,系统自动同意售后申请;
- 用户退货超时:审核通过后超时未填写物流,自动关闭售后单;
- 退款失败重试:退款异常的订单,定时重试,超过次数转人工处理。
六、平滑升级与兼容方案
6.1 升级步骤
- 执行新增表 SQL 与订单表扩展字段 SQL,不修改原生核心字段;
- 部署售后模块独立代码包,原有订单、支付代码保持不动;
- 后台先开启「售后开关」灰度,初期仅部分商品类目开放;
- 验证退款、库存、金额计算全链路正确后,全量放开。
6.2 历史数据兼容
- 历史订单默认售后状态为 0,不影响原有数据;
- 在售后期内的历史订单,允许发起售后,逻辑与新订单一致;
- 原有原生退款订单,不迁移到售后表,保留在原订单体系中,统计时合并口径即可。
6.3 官方版本升级兼容
- 核心改造均为新增表与新增 Service,极少修改原生文件;
- 官方版本迭代时,重点核对订单状态、支付退款接口是否有变动,同步适配即可;
- 状态机与事件驱动的设计,最大程度降低了版本升级的冲突概率。
七、常见坑与避坑建议
1. 状态跳转混乱
现象:售后状态随便改,后台直接更新状态字段,导致流程错乱。
避坑:所有状态变更统一走 AfterSaleService 的状态机方法,前置校验当前状态,禁止直接 updateById 改状态。
2. 退款金额计算错误
现象:优惠、运费分摊计算不对,退款金额和实付金额对不上。
避坑:复用订单下单时的分摊逻辑,按商品实付金额占比分摊优惠,保证正向逆向计算口径一致。
3. 退款幂等失效
现象:支付回调重复通知,导致多次退款或状态错乱。
避坑:按售后单号做幂等校验,退款成功后重复回调直接返回成功,不重复执行业务。
4. 库存回退重复
现象:退货入库和退款回调都触发库存回补,导致库存多增加。
避坑:明确唯一触发点,退货收货时统一回补库存,退款成功不再重复操作。
5. 超额售后
现象:一个商品多次发起售后,累计退换数量超过购买数量。
避坑:申请时校验历史已成功售后的商品数量,超额直接拦截。
结语
售后退换货的二开,看起来是加几个状态的事,本质是一套独立的逆向交易体系。
LikeShop 单商户 Java 版原生的订单、支付、库存能力已经很扎实,二开时不用推翻重构,通过新增独立售后单、状态机驱动、事件联动原生能力的方式,可以用最小的侵入成本实现完整的退换货流程,同时保留后续官方版本的可升级性。
对二开团队来说,核心原则始终是:能扩展就不修改,能解耦就不耦合。架构上分层做扎实了,后续再加运费险、上门取件、极速退款等玩法,也都是在这套骨架上做增量。
本文基于LikeShop单商户Java版,提出一套低侵入、高可扩展的售后退换货二次开发方案。核心包括:构建独立售后单体系,定义仅退款/退货退款/换货三类状态机,新增售后主表、商品明细表、操作日志表及退货物流表;通过事件驱动联动订单、库存、支付模块,实现金额分摊、库存回补、积分优惠券退回等一致性保障;支持超时自动处理、幂等退款、部分退换与历史数据兼容,确保平滑升级与官方版本长期适配。

860

被折叠的 条评论
为什么被折叠?



