跳转到内容

案例 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
规则方法 口径 分值/输出
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

将下列文件按原始包名放入 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 即可运行。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 售后申请要素
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public 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;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 售后资格决策
*/
@Data
public 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);
}
}
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);
}
<?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>
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("符合七天无理由退货条件,办理全额退款,退货运费由买家承担");
}
}
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;
}
}

在工程根目录执行:

Terminal window
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 老电商系统与信创环境,无需独立规则服务器。