LikeShop 单商户 Java 版商城售后退换货流程的自定义二开落地

本文基于LikeShop单商户Java版,提出一套低侵入、高可扩展的售后退换货二次开发方案。核心包括:构建独立售后单体系,定义仅退款/退货退款/换货三类状态机,新增售后主表、商品明细表、操作日志表及退货物流表;通过事件驱动联动订单、库存、支付模块,实现金额分摊、库存回补、积分优惠券退回等一致性保障;支持超时自动处理、幂等退款、部分退换与历史数据兼容,确保平滑升级与官方版本长期适配。

电商系统里,售后退换货是最容易出纠纷、也最容易埋坑的模块。

LikeShop 单商户 Java 版原生已经提供了基础的退款能力,但在实际业务落地时,很多客户会有更细化的诉求:区分「仅退款」与「退货退款」、支持换货、多级审核、用户寄回物流跟踪、仓库收货核验、部分商品退换、运费承担规则等。如果直接在原有订单逻辑里硬改,很容易打乱原有的订单状态机,导致资金、库存不同步。

这类需求属于典型的业务扩展型二开,最佳实践是独立售后单体系 + 状态机驱动 + 事件联动原有订单能力,不推翻原生订单逻辑,以最小侵入成本实现完整的退换货流程。

本文基于 LikeShop 单商户 Java 版的技术底座,从架构设计、表结构改造、核心流程实现、上下游联动、平滑升级五个维度,整理一套可落地的售后二开方案。

一、二开背景与核心目标

1.1 原生售后能力的局限

原生版本以订单维度的退款为主,能够满足基础的全额退款场景,但在复杂退换货场景下存在短板:

  • 流程颗粒度粗:未区分仅退款、退货退款、换货三种核心售后类型;
  • 状态节点少:缺少商家审核、用户寄回、仓库收货、退款中这些中间状态;
  • 部分退换支持弱:一个订单多件商品时,无法针对单个商品发起售后;
  • 物流与收货缺失:退货物流跟踪、仓库收货核验环节能力不足;
  • 审核规则单一:缺少超时自动同意、商家驳回可申诉等灵活配置。

1.2 二开核心目标

  1. 售后类型全覆盖:支持仅退款、退货退款、换货三种主流售后类型;
  2. 完整状态机:从申请、审核、退货、收货、退款/换货到完结,全链路状态可控,非法跳转拦截;
  3. 部分退换支持:支持按商品维度发起售后,一个订单可多次发起售后;
  4. 最小侵入原则:独立售后单体系,复用原生订单、库存、支付退款能力,不破坏原有正向流程;
  5. 可配置化:审核超时时间、运费承担规则、驳回是否可重提等可配置,适配不同行业客户。

二、核心设计思路

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 升级步骤

  1. 执行新增表 SQL 与订单表扩展字段 SQL,不修改原生核心字段;
  2. 部署售后模块独立代码包,原有订单、支付代码保持不动;
  3. 后台先开启「售后开关」灰度,初期仅部分商品类目开放;
  4. 验证退款、库存、金额计算全链路正确后,全量放开。

6.2 历史数据兼容

  • 历史订单默认售后状态为 0,不影响原有数据;
  • 在售后期内的历史订单,允许发起售后,逻辑与新订单一致;
  • 原有原生退款订单,不迁移到售后表,保留在原订单体系中,统计时合并口径即可。

6.3 官方版本升级兼容

  • 核心改造均为新增表与新增 Service,极少修改原生文件;
  • 官方版本迭代时,重点核对订单状态、支付退款接口是否有变动,同步适配即可;
  • 状态机与事件驱动的设计,最大程度降低了版本升级的冲突概率。

七、常见坑与避坑建议

1. 状态跳转混乱

现象:售后状态随便改,后台直接更新状态字段,导致流程错乱。
避坑:所有状态变更统一走 AfterSaleService 的状态机方法,前置校验当前状态,禁止直接 updateById 改状态。

2. 退款金额计算错误

现象:优惠、运费分摊计算不对,退款金额和实付金额对不上。
避坑:复用订单下单时的分摊逻辑,按商品实付金额占比分摊优惠,保证正向逆向计算口径一致。

3. 退款幂等失效

现象:支付回调重复通知,导致多次退款或状态错乱。
避坑:按售后单号做幂等校验,退款成功后重复回调直接返回成功,不重复执行业务。

4. 库存回退重复

现象:退货入库和退款回调都触发库存回补,导致库存多增加。
避坑:明确唯一触发点,退货收货时统一回补库存,退款成功不再重复操作。

5. 超额售后

现象:一个商品多次发起售后,累计退换数量超过购买数量。
避坑:申请时校验历史已成功售后的商品数量,超额直接拦截。

结语

售后退换货的二开,看起来是加几个状态的事,本质是一套独立的逆向交易体系。

LikeShop 单商户 Java 版原生的订单、支付、库存能力已经很扎实,二开时不用推翻重构,通过新增独立售后单、状态机驱动、事件联动原生能力的方式,可以用最小的侵入成本实现完整的退换货流程,同时保留后续官方版本的可升级性。

对二开团队来说,核心原则始终是:能扩展就不修改,能解耦就不耦合。架构上分层做扎实了,后续再加运费险、上门取件、极速退款等玩法,也都是在这套骨架上做增量。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

肥仔全栈开发

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值