案例 27:售后退款/退货/换货资格判定
本案例对应 JQuick-Java 仓库 USE-CASES 场景 27「售后退款/退货/换货资格判定」,属于电商零售领域。系统对一笔售后申请自动判定应走退货退款、换货还是驳回,给出退款比例、运费承担方与可对客展示的审核结论,判定优先级为:质量问题 → 类目禁退 → 换货 → 七天无理由。
买家签收商品后可能因质量问题、尺码不符、描述不符或单纯多拍不想要而发起售后。平台客服每天面对大量申请,需要依据签收天数、商品现状、凭证、责任归属与商品类目统一裁决口径,避免不同客服给出相互矛盾的结论。
质量问题(申请类型为 QUALITY 或商品现状为到货破损 DAMAGED)优先级最高:签收 15 天异议期内,且买家提供质量凭证或商家认责,即办理全额退货退款(RETURN_REFUND,退款比例 1.0),退货运费由商家承担(MERCHANT);超期或举证不足则驳回。
非质量类申请先过类目门槛:定制(CUSTOM)、虚拟(VIRTUAL)、生鲜(FRESH)类目不支持七天无理由退货(生鲜质量问题除外),直接驳回并给出对应类目文案。尺码不符(SIZE)走换货通道,限签收 15 天内,换货运费买家承担;描述不符(MISMATCH)属商家责任,限 30 天内,往返运费商家承担。其余申请落入七天无理由:7 天内、未使用、密封完好且不影响二次销售四项条件同时满足才全额退款、运费买家自理(BUYER),任一不满足即驳回。
决策输出物为 AfterSalesDecision:处置动作(RETURN_REFUND/EXCHANGE/REJECT)、退款比例、运费承担方(MERCHANT/BUYER/NONE)以及逐条审核结论说明(reasons),可直接回传客服工作台与买家售后详情页。
- 售后政策随消保法规与平台规则频繁调整(如质量异议期、尺码/描述不符换货期限、类目禁退清单),期限与文案硬编码在系统里,每次政策更新都要发版。
- 质量、换货、无理由三条通道期限口径不同(15 天/30 天/7 天),与类目禁退、凭证责任判定交织在一大段分支代码中,新人维护极易改出漏洞。
- 运费承担方(商家/买家/不涉及)随申请原因变化,财政与商家结算都要可审计,黑盒判定一旦出错会直接引发商家投诉。
- 生鲜「质量可退、无理由拒退」这类同类目分叉、以及「无凭证但商家认责也可全退」的责任分叉,需要在决策链中有明确优先级并能回溯原因。
- 大量电商老系统仍在 JDK 8 与信创 JDK 上运行,难以额外引入需要独立部署的重型规则平台。
- 客服与买家需要可读的裁决理由(如「密封破损」「超 15 天异议期」),仅有 true/false 的规则结果无法直接对客解释。
本案例采用「入参模型 + 决策模型 + 规则接口 + XML 规则脚本 + Service 编排 + JUnit 测试」六件套结构:四个规则方法分别封装资格布尔判定与运费映射,期限阈值全部外置在 XML,Service 负责申请分流、短路返回与文案装配。
| 层次 | 职责 | 对应文件 |
|---|---|---|
| 领域模型层 | 承载售后申请要素,输出处置动作、退款比例、运费方与结论说明 | AfterSalesRequest.java、AfterSalesDecision.java |
| 规则接口层 | 声明三类资格判定与运费承担方法,@Param 按名绑定 |
AfterSalesRules.java |
| 规则脚本层 | 7/15/30 天期限门槛与运费映射外置,支持热更新 | after-sales.xml |
| 编排与测试层 | 质量→类目→换货→无理由优先级分流、短路返回、文案组装;6 组形态回归 | AfterSalesService.java、AfterSalesServiceTest.java |
规则方法拆分
Section titled “规则方法拆分”| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
| sevenDayEligible | 签收 ≤7 天、未使用、密封完好、不影响二次销售,四项条件同时成立才通过 | true/false |
| qualityEligible | 签收 ≤15 天异议期内,且有质量凭证或商家认责,二者满足其一即可 | true/false |
| exchangeEligible | 原因 SIZE 限 ≤15 天;原因 MISMATCH 限 ≤30 天;其他原因一律 false | true/false |
| freightPayer | QUALITY、MISMATCH 返回 MERCHANT;SIZE、SEVEN_DAY 及其他原因返回 BUYER | MERCHANT/BUYER |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将下列文件按原始包名放入 JQuick-Java 测试工程:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试类放入 src/test/java/com/github/paohaijiao/demo/,Service 放入 src/test/java/com/github/paohaijiao/demo/service/,XML 脚本放入 src/test/resources/demo/。在工程根目录执行 mvn test -Dtest=AfterSalesServiceTest 即可运行。
AfterSalesRequest.java
Section titled “AfterSalesRequest.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 售后申请要素 */@Data@NoArgsConstructor@AllArgsConstructorpublic class AfterSalesRequest {
/** * 订单号 */ private String orderId;
/** * 商品编码 */ private String sku;
/** * 商品类目:STANDARD 普通 / FRESH 生鲜 / CUSTOM 定制 / VIRTUAL 虚拟 */ private String productCategory;
/** * 签收后天数 */ private int daysSinceReceived;
/** * 商品现状:UNUSED 未使用 / USED 已使用 / DAMAGED 到货破损 */ private String productCondition;
/** * 申请类型:QUALITY 质量问题 / SEVEN_DAY 七天无理由 / SIZE 尺码不符 / MISMATCH 描述不符 / CHANGE_MIND 多拍不想要 */ private String reasonType;
/** * 是否提供质量问题凭证 */ private boolean qualityProof;
/** * 是否商家责任(如发错货) */ private boolean shopResponsible;
/** * 商品密封/包装是否完好 */ private boolean sealedIntact;
/** * 是否已产生影响二次销售的使用痕迹 */ private boolean impactResale;}AfterSalesDecision.java
Section titled “AfterSalesDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 售后资格决策 */@Datapublic class AfterSalesDecision {
/** * 订单号 */ private String orderId;
/** * 处置动作:RETURN_REFUND 退货退款 / EXCHANGE 换货 / REJECT 驳回 */ private String action;
/** * 退款比例 */ private double refundRatio;
/** * 运费承担方:MERCHANT 商家 / BUYER 买家 / NONE 不涉及 */ private String freightPayer;
/** * 审核结论说明 */ private List<String> reasons = new ArrayList<String>();
public void addReason(String reason) { this.reasons.add(reason); }}AfterSalesRules.java
Section titled “AfterSalesRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 27:售后退款/退货/换货资格规则 */public interface AfterSalesRules {
/** * 七天无理由退货资格:7 天内、未使用、密封完好、不影响二次销售 */ boolean sevenDayEligible(@Param("days") int days, @Param("unused") boolean unused, @Param("sealed") boolean sealed, @Param("impact") boolean impact);
/** * 质量问题退货资格:15 天异议期内,且有凭证或商家认责 */ boolean qualityEligible(@Param("days") int days, @Param("proof") boolean proof, @Param("shopResponsible") boolean shopResponsible);
/** * 换货资格:尺码问题 15 天内,描述不符(商家责任)30 天内 */ boolean exchangeEligible(@Param("reason") String reason, @Param("days") int days);
/** * 运费承担方判定 */ String freightPayer(@Param("reason") String reason);}after-sales.xml
Section titled “after-sales.xml”<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN" "classpath:paohaijiao/dtd/Jquick-java.dtd"><javas namespace="com.github.paohaijiao.demo.AfterSalesRules">
<java name="sevenDayEligible" returnClass="boolean"> <![CDATA[ boolean def sevenDayEligible(int:days, boolean:unused, boolean:sealed, boolean:impact) { if (days <= 7) { if (unused) { if (sealed) { if (impact) { return false; } else { return true; } } else { return false; } } else { return false; } } else { return false; } } ]]> </java>
<java name="qualityEligible" returnClass="boolean"> <![CDATA[ boolean def qualityEligible(int:days, boolean:proof, boolean:shopResponsible) { if (days <= 15) { if (proof) { return true; } else { if (shopResponsible) { return true; } else { return false; } } } else { return false; } } ]]> </java>
<java name="exchangeEligible" returnClass="boolean"> <![CDATA[ boolean def exchangeEligible(str:reason, int:days) { if (reason == "SIZE") { if (days <= 15) { return true; } else { return false; } } else if (reason == "MISMATCH") { if (days <= 30) { return true; } else { return false; } } else { return false; } } ]]> </java>
<java name="freightPayer" returnClass="java.lang.String"> <![CDATA[ str def freightPayer(str:reason) { if (reason == "QUALITY") { return "MERCHANT"; } else if (reason == "MISMATCH") { return "MERCHANT"; } else if (reason == "SIZE") { return "BUYER"; } else if (reason == "SEVEN_DAY") { return "BUYER"; } else { return "BUYER"; } } ]]> </java>
</javas>AfterSalesService.java
Section titled “AfterSalesService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.AfterSalesRules;import com.github.paohaijiao.demo.model.AfterSalesDecision;import com.github.paohaijiao.demo.model.AfterSalesRequest;
/** * 场景 27:售后退款/退货/换货资格判定 * <p> * 判定优先级:质量问题 → 类目禁退 → 换货 → 七天无理由。 */public class AfterSalesService {
private final AfterSalesRules rules;
public AfterSalesService(AfterSalesRules rules) { this.rules = rules; }
public AfterSalesDecision review(AfterSalesRequest req) { AfterSalesDecision d = new AfterSalesDecision(); d.setOrderId(req.getOrderId());
boolean qualityClaim = "QUALITY".equals(req.getReasonType()) || "DAMAGED".equals(req.getProductCondition());
if (qualityClaim) { handleQuality(req, d); return d; }
if ("CUSTOM".equals(req.getProductCategory()) || "VIRTUAL".equals(req.getProductCategory()) || "FRESH".equals(req.getProductCategory())) { reject(d, categoryText(req.getProductCategory())); return d; }
if ("SIZE".equals(req.getReasonType()) || "MISMATCH".equals(req.getReasonType())) { handleExchange(req, d); return d; }
handleSevenDay(req, d); return d; }
private void handleQuality(AfterSalesRequest req, AfterSalesDecision d) { boolean eligible = rules.qualityEligible(req.getDaysSinceReceived(), req.isQualityProof(), req.isShopResponsible()); if (!eligible) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason("超出 15 天质量异议期或举证不足,不予受理质量退货"); return; } d.setAction("RETURN_REFUND"); d.setRefundRatio(1.0); d.setFreightPayer("MERCHANT"); if (req.isShopResponsible()) { d.addReason("商家责任认定成立,办理全额退货退款,退货运费由商家承担"); } else { d.addReason("质量问题凭证核验通过,办理全额退货退款,退货运费由商家承担"); } }
private String categoryText(String category) { if ("FRESH".equals(category)) { return "生鲜易腐商品不支持七天无理由退货(质量问题除外)"; } if ("CUSTOM".equals(category)) { return "定制商品不支持七天无理由退货"; } if ("VIRTUAL".equals(category)) { return "虚拟商品一经交付不支持退货"; } return "该商品类目不支持无理由退货"; }
private void reject(AfterSalesDecision d, String categoryText) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason(categoryText); }
private void handleExchange(AfterSalesRequest req, AfterSalesDecision d) { boolean eligible = rules.exchangeEligible(req.getReasonType(), req.getDaysSinceReceived()); if (!eligible) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason("已超过换货申请期限(尺码问题 15 天、描述不符 30 天)"); return; } d.setAction("EXCHANGE"); d.setRefundRatio(0.0); d.setFreightPayer(rules.freightPayer(req.getReasonType())); if ("MISMATCH".equals(req.getReasonType())) { d.addReason("商品与描述不符,30 天内支持换货,往返运费由商家承担"); } else { d.addReason("尺码不符 15 天内支持换货,换货运费由买家承担"); } }
private void handleSevenDay(AfterSalesRequest req, AfterSalesDecision d) { boolean eligible = rules.sevenDayEligible(req.getDaysSinceReceived(), "UNUSED".equals(req.getProductCondition()), req.isSealedIntact(), req.isImpactResale()); if (!eligible) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason("不符合七天无理由退货条件(超期、已使用、密封破损或影响二次销售)"); return; } d.setAction("RETURN_REFUND"); d.setRefundRatio(1.0); d.setFreightPayer("BUYER"); d.addReason("符合七天无理由退货条件,办理全额退款,退货运费由买家承担"); }}AfterSalesServiceTest.java
Section titled “AfterSalesServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.AfterSalesDecision;import com.github.paohaijiao.demo.model.AfterSalesRequest;import com.github.paohaijiao.demo.service.AfterSalesService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 27:售后退款/退货/换货资格判定 * <p> * 覆盖 6 组售后形态:七天无理由退货、质量退货、尺码换货、 * 无理由条件不符双驳回、生鲜类目质量/无理由分叉、超期无凭证与商家认责分叉。 */public class AfterSalesServiceTest {
private AfterSalesService service;
@Before public void setUp() { AfterSalesRules rules = JQuickJava.create() .createApi(AfterSalesRules.class, "demo/after-sales.xml"); this.service = new AfterSalesService(rules); }
/** * 普通商品签收 3 天、未使用、密封完好 → 七天无理由退货退款,买家承担运费 */ @Test public void shouldApproveSevenDayReturn() { AfterSalesRequest req = base("AS001", "STANDARD", "SEVEN_DAY"); req.setDaysSinceReceived(3); req.setProductCondition("UNUSED"); req.setSealedIntact(true); req.setImpactResale(false);
AfterSalesDecision d = service.review(req);
Assert.assertEquals("RETURN_REFUND", d.getAction()); Assert.assertEquals(1.0, d.getRefundRatio(), 0.0001); Assert.assertEquals("BUYER", d.getFreightPayer()); }
/** * 到货破损 10 天、凭证齐全 → 质量退货,商家承担运费 */ @Test public void shouldApproveQualityReturnWithProof() { AfterSalesRequest req = base("AS002", "STANDARD", "QUALITY"); req.setDaysSinceReceived(10); req.setProductCondition("DAMAGED"); req.setQualityProof(true);
AfterSalesDecision d = service.review(req);
Assert.assertEquals("RETURN_REFUND", d.getAction()); Assert.assertEquals(1.0, d.getRefundRatio(), 0.0001); Assert.assertEquals("MERCHANT", d.getFreightPayer()); Assert.assertTrue(d.getReasons().contains( "质量问题凭证核验通过,办理全额退货退款,退货运费由商家承担")); }
/** * 已穿用但 8 天内尺码不符 → 换货,买家承担换货运费 */ @Test public void shouldApproveSizeExchange() { AfterSalesRequest req = base("AS003", "STANDARD", "SIZE"); req.setDaysSinceReceived(8); req.setProductCondition("USED");
AfterSalesDecision d = service.review(req);
Assert.assertEquals("EXCHANGE", d.getAction()); Assert.assertEquals(0.0, d.getRefundRatio(), 0.0001); Assert.assertEquals("BUYER", d.getFreightPayer()); Assert.assertTrue(d.getReasons().contains("尺码不符 15 天内支持换货,换货运费由买家承担")); }
/** * 同样 5 天无理由:有使用痕迹或密封破损 → 均驳回 */ @Test public void shouldRejectSevenDayWhenConditionNotMet() { AfterSalesRequest used = base("AS004", "STANDARD", "SEVEN_DAY"); used.setDaysSinceReceived(5); used.setProductCondition("UNUSED"); used.setSealedIntact(true); used.setImpactResale(true); AfterSalesDecision d1 = service.review(used); Assert.assertEquals("REJECT", d1.getAction()); Assert.assertTrue(d1.getReasons().get(0).contains("不符合七天无理由退货条件"));
AfterSalesRequest unsealed = base("AS005", "STANDARD", "SEVEN_DAY"); unsealed.setDaysSinceReceived(5); unsealed.setProductCondition("UNUSED"); unsealed.setSealedIntact(false); unsealed.setImpactResale(false); AfterSalesDecision d2 = service.review(unsealed); Assert.assertEquals("REJECT", d2.getAction()); }
/** * 生鲜:质量问题可退;无理由拒退 */ @Test public void shouldSplitFreshQualityAndSevenDay() { AfterSalesRequest quality = base("AS006", "FRESH", "QUALITY"); quality.setDaysSinceReceived(2); quality.setProductCondition("DAMAGED"); quality.setQualityProof(true); AfterSalesDecision d1 = service.review(quality); Assert.assertEquals("RETURN_REFUND", d1.getAction()); Assert.assertEquals("MERCHANT", d1.getFreightPayer());
AfterSalesRequest sevenDay = base("AS007", "FRESH", "SEVEN_DAY"); sevenDay.setDaysSinceReceived(2); sevenDay.setProductCondition("UNUSED"); sevenDay.setSealedIntact(true); AfterSalesDecision d2 = service.review(sevenDay); Assert.assertEquals("REJECT", d2.getAction()); Assert.assertTrue(d2.getReasons().contains("生鲜易腐商品不支持七天无理由退货(质量问题除外)")); }
/** * 超期无凭证驳回;异议期内商家认责即便无凭证也全退 */ @Test public void shouldHandleOverdueProofMissingAndShopResponsible() { AfterSalesRequest overdue = base("AS008", "STANDARD", "QUALITY"); overdue.setDaysSinceReceived(20); overdue.setProductCondition("DAMAGED"); overdue.setQualityProof(false); overdue.setShopResponsible(false); AfterSalesDecision d1 = service.review(overdue); Assert.assertEquals("REJECT", d1.getAction()); Assert.assertEquals("NONE", d1.getFreightPayer());
AfterSalesRequest shopFault = base("AS009", "STANDARD", "QUALITY"); shopFault.setDaysSinceReceived(10); shopFault.setProductCondition("DAMAGED"); shopFault.setQualityProof(false); shopFault.setShopResponsible(true); AfterSalesDecision d2 = service.review(shopFault); Assert.assertEquals("RETURN_REFUND", d2.getAction()); Assert.assertEquals("MERCHANT", d2.getFreightPayer()); Assert.assertTrue(d2.getReasons().contains( "商家责任认定成立,办理全额退货退款,退货运费由商家承担")); }
private AfterSalesRequest base(String orderId, String category, String reason) { AfterSalesRequest r = new AfterSalesRequest(); r.setOrderId(orderId); r.setSku("SKU100"); r.setProductCategory(category); r.setDaysSinceReceived(0); r.setProductCondition("UNUSED"); r.setReasonType(reason); r.setQualityProof(false); r.setShopResponsible(false); r.setSealedIntact(true); r.setImpactResale(false); return r; }}在工程根目录执行:
mvn test -Dtest=AfterSalesServiceTest测试覆盖形态(共 6 个 @Test 方法):
| 测试方法 | 业务形态 | 关键断言/期望决策 |
|---|---|---|
| shouldApproveSevenDayReturn | 普通商品签收 3 天、UNUSED、密封完好、不影响二次销售 | action=RETURN_REFUND、refundRatio=1.0、freightPayer=BUYER |
| shouldApproveQualityReturnWithProof | 到货破损 10 天、QUALITY、凭证齐全 | RETURN_REFUND、1.0、MERCHANT;原因含「质量问题凭证核验通过,办理全额退货退款,退货运费由商家承担」 |
| shouldApproveSizeExchange | 已穿用(USED)但 8 天内尺码不符 | action=EXCHANGE、refundRatio=0.0、freightPayer=BUYER;含「尺码不符 15 天内支持换货,换货运费由买家承担」 |
| shouldRejectSevenDayWhenConditionNotMet | 同为签收 5 天无理由:一单影响二次销售、一单密封破损 | 两单均 REJECT;d1 首条原因包含「不符合七天无理由退货条件」 |
| shouldSplitFreshQualityAndSevenDay | 生鲜类目两单:2 天破损有凭证走质量;2 天未使用走无理由 | 质量单 RETURN_REFUND + MERCHANT;无理由单 REJECT,含「生鲜易腐商品不支持七天无理由退货(质量问题除外)」 |
| shouldHandleOverdueProofMissingAndShopResponsible | 20 天破损无凭证不认责 vs 10 天破损无凭证但商家认责 | 前者 REJECT + NONE;后者 RETURN_REFUND + MERCHANT,含「商家责任认定成立,办理全额退货退款,退货运费由商家承担」 |
- 期限与类目口径外置热更新:7/15/30 天门槛、原因到运费方的映射全部在 after-sales.xml 中,售后政策调整只改 XML,不动客服主系统。
- 一方法一口径:四个规则方法分别回答「无理由是否成立」「质量是否成立」「换货是否成立」「谁付运费」,每条裁决都能定位到具体规则方法,审计与举证清晰。
- Service 优先级短路:质量 → 类目禁退 → 换货 → 无理由的顺序即业务优先级,命中即返回;生鲜「质量可退、无理由拒退」、凭证与认责分叉都由分流顺序保证。
- 正向 return 规避取反:XML 中资格脚本统一用嵌套
if配合「正向条件 return true、否则 return false」的写法表达多条件与逻辑,不使用!运算符,期限边界(≤)一目了然。 - 决策带可读文案:规则只输出布尔与枚举,Service 负责装配对客原因,规则稳定性与文案灵活性分离。
- 轻量无重型依赖:接口由 ASM 动态代理实现,Java 8 起步即可运行,可直接嵌入 JDK 8 老电商系统与信创环境,无需独立规则服务器。