跳转到内容

案例 10:优惠券发放规则

本案例对应 JQuick-Java 仓库 USE-CASES.md 的场景 10:优惠券发放规则,属于营销/电商领域,解决用户下单或领券时「基础满减 + 新客/忠诚/品类奖励叠加 → 券类型归类 → 券名/门槛/有效期/叠加规则 → 风控与人工复核」的自动化发券决策问题。

电商平台在日常经营与大促期间需要按用户画像与当次订单实时发券。发券请求携带:用户编号、会员等级(NORMAL/SILVER/GOLD/VIP)、本次订单金额、历史订单数、商品品类(DIGITAL/FRESH/APPAREL/GENERAL)、注册天数、距上次下单天数、历史客单价、是否新客、是否已领过本活动券、活动是否生效、券库存、领取渠道(APP/WEB/MINI)以及风控标记。

优惠金额由四部分独立计算后叠加:基础满减按订单金额 ≥1000/≥500/≥200 三档与 VIP/GOLD/SILVER/NORMAL 四等级构成矩阵(最高 200 元);新客奖励为注册 7 天内 +30 元;忠诚奖励为下单满 50 单且 VIP/GOLD 加 50 元、满 20 单加 20 元;品类奖励为活动期内生鲜加 15 元、数码加 10 元。

券类型按业务优先级唯一归类:新客券(NEW_USER)优先于 VIP 大额券(VIP_EXCLUSIVE)、大额满减券(LARGE_ORDER)、忠诚回馈券(LOYALTY)、生鲜品类券(CATEGORY)、普通满减券(ORDER),全部未命中则为 NONE。类型确定后再映射中文券名、与优惠金额对应的使用门槛(满 2000 减 200 直至满 100 减 10 共 7 档)、有效期(新客券 7 天、VIP 券 30 天、大额券 15 天、其余 10 天)以及能否叠加(已领券或新客券不可叠加)。

风控侧并行输出两条结论:命中风控标记或优惠金额达到 200 元的需转人工复核;风控账户风险等级 HIGH,注册不足 3 天且无下单的新客 MEDIUM,其余 LOW。未命中任何发券策略时券类型为 NONE,Service 将优惠金额统一清零。场景 10 覆盖 6 组形态:新客首单 30 元券、VIP 2500 元订单 200 元券转人工、SILVER 大额满减 100 元、GOLD 忠诚会员活动期生鲜单 115 元、风控账户 230 元券挂起、120 元普通单不发券。

  • 满减矩阵(3 档订单金额 × 4 个会员等级)与新客、忠诚、品类三类奖励分散在多个营销活动代码中,同一用户在不同入口算出的金额不一致。
  • 券类型存在严格业务优先级(新客 > VIP 大额 > 大额满减 > 忠诚 > 品类 > 普通),用硬编码 if-else 实现时,分支顺序一动就可能发错券种。
  • 使用门槛 7 档、有效期 4 档随大促节奏频繁调整,每次都要排期发版,运营无法自助配置。
  • 风控标记、200 元人工复核线、不可叠加规则与发券主流程耦合,风控策略升级时要改动营销核心代码。
  • 各奖励金额独立叠加后,未命中任何策略的订单仍可能残留奖励金额,缺少统一的清零收口。
  • 营销系统多为 JDK 8 老系统或信创环境,团队不希望为一个发券能力引入重型规则平台。

本案例采用标准「六件套」:入参模型承载用户与订单快照,决策结果模型承载券类型、金额、门槛与风控结论,规则接口声明 11 个规则方法,XML 脚本外置满减矩阵、奖励口径与全部阈值,Service 负责四段金额汇总、NONE 清零与发放依据组装,JUnit 以 6 组发券形态回归。

层次 职责 对应文件
领域模型 发券请求、发券决策结果 CouponRequest.java、CouponDecision.java
规则集与 XML 基础满减 + 三类奖励 + 券类型/券名/门槛/有效期/风控共 11 个方法 CouponRules.java、coupon.xml
编排服务 四段优惠金额汇总、未命中清零、复核与叠加判定、发放依据组装 CouponService.java
测试 6 组发券形态(新客/VIP/大额/忠诚/风控/未命中) CouponServiceTest.java
规则方法 口径 分值/输出
baseDiscount 订单 ≥1000:VIP 200/GOLD 150/SILVER 100/其余 60;≥500:100/80/50/30;≥200:VIP 40/其余 20;否则 0 0-200 元
newUserBonus 新客且注册 ≤7 天 +30,否则 0 0/30 元
loyaltyBonus 下单 ≥50 且 VIP/GOLD +50;≥20 单 +20;否则 0 0/20/50 元
categoryBonus 活动期生鲜 FRESH +15、数码 DIGITAL +10,否则 0 0/10/15 元
couponType 新客 > VIP 大额 > 大额满减 > 忠诚 > 生鲜品类 > 普通满减 > 无 NEW_USER/VIP_EXCLUSIVE/LARGE_ORDER/LOYALTY/CATEGORY/ORDER/NONE
couponName 金额 ≤0 为「无可用券」,否则按券类型映射中文券名 中文券名
threshold 金额 ≥200→2000 / ≥150→1500 / ≥100→1000 / ≥60→600 / ≥40→400 / ≥20→200 / ≥10→100 / 否则 0 使用门槛(元)
validDays NEW_USER 7 / VIP_EXCLUSIVE 30 / LARGE_ORDER 15 / 其余 10 有效期(天)
needManualReview 命中风控标记或优惠金额 ≥200 元 → 转人工复核 true / false
stackable 已领过本活动券或券类型为新客券 → 不可叠加,其余可叠加 true / false
riskLevel 风控标记 HIGH;注册不足 3 天且无历史下单 MEDIUM;否则 LOW HIGH/MEDIUM/LOW

将六个文件按仓库原有包结构放入 Maven 工程:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试放入 com.github.paohaijiao.demo 包,编排服务放入 com.github.paohaijiao.demo.service 包,XML 脚本放入 src/test/resources/demo/coupon.xml,包名全部保持源码原样。工程根目录执行 mvn test -Dtest=CouponServiceTest 即可运行。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 优惠券发放请求(发券决策所需的用户与订单快照)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CouponRequest {
/**
* 用户编号
*/
private String userId;
/**
* 会员等级:NORMAL / SILVER / GOLD / VIP
*/
private String memberLevel;
/**
* 本次订单金额
*/
private double orderAmount;
/**
* 历史订单数
*/
private int orderCount;
/**
* 商品品类:DIGITAL / FRESH / APPAREL / GENERAL
*/
private String category;
/**
* 注册天数
*/
private int joinDays;
/**
* 距上次下单天数
*/
private int lastOrderDays;
/**
* 历史客单价
*/
private double avgOrderAmount;
/**
* 是否新客
*/
private boolean newUser;
/**
* 是否已领取过本活动券
*/
private boolean hasUsedCoupon;
/**
* 活动是否在有效期
*/
private boolean campaignActive;
/**
* 券库存余量
*/
private int stockLeft;
/**
* 领取渠道:APP / WEB / MINI
*/
private String channel;
/**
* 风控标记
*/
private boolean riskFlag;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 优惠券发放决策结果
*/
@Data
public class CouponDecision {
/**
* 用户编号
*/
private String userId;
/**
* 券类型:NEW_USER / VIP_EXCLUSIVE / LARGE_ORDER / LOYALTY / CATEGORY / ORDER / NONE
*/
private String couponType;
/**
* 券名称
*/
private String couponName;
/**
* 优惠金额
*/
private double discountAmount;
/**
* 使用门槛(满 X 可用)
*/
private double threshold;
/**
* 有效期(天)
*/
private int validDays;
/**
* 是否可叠加
*/
private boolean stackable;
/**
* 是否需要人工复核
*/
private boolean needManualReview;
/**
* 风控等级:HIGH / MEDIUM / LOW
*/
private String riskLevel;
/**
* 发放依据
*/
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;
/**
* 场景 10:优惠券发放规则
* <p>
* 基础满减按会员等级与订单金额分档,叠加新客/忠诚/品类奖励后确定券类型、门槛与风控处置。
*/
public interface CouponRules {
double baseDiscount(@Param("memberLevel") String memberLevel,
@Param("orderAmount") double orderAmount);
double newUserBonus(@Param("newUser") boolean newUser,
@Param("joinDays") int joinDays);
double loyaltyBonus(@Param("memberLevel") String memberLevel,
@Param("orderCount") int orderCount);
double categoryBonus(@Param("category") String category,
@Param("campaignActive") boolean campaignActive);
String couponType(@Param("memberLevel") String memberLevel,
@Param("orderAmount") double orderAmount,
@Param("newUser") boolean newUser,
@Param("joinDays") int joinDays,
@Param("orderCount") int orderCount,
@Param("category") String category,
@Param("campaignActive") boolean campaignActive);
String couponName(@Param("couponType") String couponType,
@Param("discountAmount") double discountAmount);
double threshold(@Param("discountAmount") double discountAmount);
int validDays(@Param("couponType") String couponType);
boolean needManualReview(@Param("riskFlag") boolean riskFlag,
@Param("discountAmount") double discountAmount);
boolean stackable(@Param("couponType") String couponType,
@Param("hasUsedCoupon") boolean hasUsedCoupon);
String riskLevel(@Param("riskFlag") boolean riskFlag,
@Param("orderCount") int orderCount,
@Param("joinDays") int joinDays);
}
<?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.CouponRules">
<!-- 基础满减:会员等级 × 订单金额 -->
<java name="baseDiscount" returnClass="double">
<![CDATA[
double def baseDiscount(str:memberLevel, double:orderAmount) {
if (orderAmount >= 1000.0) {
if (memberLevel == "VIP") {
return 200.0;
} else if (memberLevel == "GOLD") {
return 150.0;
} else if (memberLevel == "SILVER") {
return 100.0;
} else {
return 60.0;
}
} else if (orderAmount >= 500.0) {
if (memberLevel == "VIP") {
return 100.0;
} else if (memberLevel == "GOLD") {
return 80.0;
} else if (memberLevel == "SILVER") {
return 50.0;
} else {
return 30.0;
}
} else if (orderAmount >= 200.0) {
if (memberLevel == "VIP") {
return 40.0;
} else {
return 20.0;
}
} else {
return 0.0;
}
}
]]>
</java>
<!-- 新客奖励:注册 7 天内 -->
<java name="newUserBonus" returnClass="double">
<![CDATA[
double def newUserBonus(boolean:newUser, int:joinDays) {
if (newUser && joinDays <= 7) {
return 30.0;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 忠诚奖励:下单频次 -->
<java name="loyaltyBonus" returnClass="double">
<![CDATA[
double def loyaltyBonus(str:memberLevel, int:orderCount) {
if (orderCount >= 50 && (memberLevel == "VIP" || memberLevel == "GOLD")) {
return 50.0;
} else if (orderCount >= 20) {
return 20.0;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 品类奖励:活动期生鲜/数码 -->
<java name="categoryBonus" returnClass="double">
<![CDATA[
double def categoryBonus(str:category, boolean:campaignActive) {
if (campaignActive && category == "FRESH") {
return 15.0;
} else if (campaignActive && category == "DIGITAL") {
return 10.0;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 券类型(按业务优先级归类) -->
<java name="couponType" returnClass="java.lang.String">
<![CDATA[
str def couponType(str:memberLevel, double:orderAmount, boolean:newUser, int:joinDays,
int:orderCount, str:category, boolean:campaignActive) {
if (newUser && joinDays <= 7 && orderAmount >= 50.0) {
return "NEW_USER";
} else if (memberLevel == "VIP" && orderAmount >= 1000.0) {
return "VIP_EXCLUSIVE";
} else if (orderAmount >= 1000.0) {
return "LARGE_ORDER";
} else if (orderCount >= 20 && (memberLevel == "VIP" || memberLevel == "GOLD")) {
return "LOYALTY";
} else if (campaignActive && category == "FRESH") {
return "CATEGORY";
} else if (orderAmount >= 200.0) {
return "ORDER";
} else {
return "NONE";
}
}
]]>
</java>
<!-- 券名称 -->
<java name="couponName" returnClass="java.lang.String">
<![CDATA[
str def couponName(str:couponType, double:discountAmount) {
if (discountAmount <= 0.0) {
return "无可用券";
} else if (couponType == "NEW_USER") {
return "新客专享券";
} else if (couponType == "VIP_EXCLUSIVE") {
return "VIP专享券";
} else if (couponType == "LARGE_ORDER") {
return "大额满减券";
} else if (couponType == "LOYALTY") {
return "忠诚回馈券";
} else if (couponType == "CATEGORY") {
return "生鲜品类券";
} else {
return "满减券";
}
}
]]>
</java>
<!-- 使用门槛 -->
<java name="threshold" returnClass="double">
<![CDATA[
double def threshold(double:discountAmount) {
if (discountAmount >= 200.0) {
return 2000.0;
} else if (discountAmount >= 150.0) {
return 1500.0;
} else if (discountAmount >= 100.0) {
return 1000.0;
} else if (discountAmount >= 60.0) {
return 600.0;
} else if (discountAmount >= 40.0) {
return 400.0;
} else if (discountAmount >= 20.0) {
return 200.0;
} else if (discountAmount >= 10.0) {
return 100.0;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 有效期 -->
<java name="validDays" returnClass="int">
<![CDATA[
int def validDays(str:couponType) {
if (couponType == "NEW_USER") {
return 7;
} else if (couponType == "VIP_EXCLUSIVE") {
return 30;
} else if (couponType == "LARGE_ORDER") {
return 15;
} else {
return 10;
}
}
]]>
</java>
<!-- 是否人工复核 -->
<java name="needManualReview" returnClass="boolean">
<![CDATA[
boolean def needManualReview(boolean:riskFlag, double:discountAmount) {
if (riskFlag) {
return true;
} else if (discountAmount >= 200.0) {
return true;
} else {
return false;
}
}
]]>
</java>
<!-- 是否可叠加 -->
<java name="stackable" returnClass="boolean">
<![CDATA[
boolean def stackable(str:couponType, boolean:hasUsedCoupon) {
if (hasUsedCoupon) {
return false;
} else if (couponType == "NEW_USER") {
return false;
} else {
return true;
}
}
]]>
</java>
<!-- 风控等级 -->
<java name="riskLevel" returnClass="java.lang.String">
<![CDATA[
str def riskLevel(boolean:riskFlag, int:orderCount, int:joinDays) {
if (riskFlag) {
return "HIGH";
} else if (joinDays < 3 && orderCount <= 0) {
return "MEDIUM";
} else {
return "LOW";
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.CouponRules;
import com.github.paohaijiao.demo.model.CouponDecision;
import com.github.paohaijiao.demo.model.CouponRequest;
/**
* 场景 10:优惠券发放服务
* <p>
* 职责划分:
* <ul>
* <li><b>Java 侧</b>:优惠金额汇总、券类型与约束装配、发放依据组装</li>
* <li><b>规则侧</b>:满减分档、新客/忠诚/品类奖励、券类型优先级、门槛与风控阈值,全部外置在 XML 中</li>
* </ul>
*/
public class CouponService {
private final CouponRules rules;
public CouponService(CouponRules rules) {
this.rules = rules;
}
/**
* 计算发券决策
*
* @param request 用户与订单快照
* @return 发券决策
*/
public CouponDecision issue(CouponRequest request) {
CouponDecision decision = new CouponDecision();
decision.setUserId(request.getUserId());
double base = rules.baseDiscount(request.getMemberLevel(), request.getOrderAmount());
double newUserBonus = rules.newUserBonus(request.isNewUser(), request.getJoinDays());
double loyaltyBonus = rules.loyaltyBonus(request.getMemberLevel(), request.getOrderCount());
double categoryBonus = rules.categoryBonus(request.getCategory(), request.isCampaignActive());
double discountAmount = base + newUserBonus + loyaltyBonus + categoryBonus;
String couponType = rules.couponType(request.getMemberLevel(), request.getOrderAmount(),
request.isNewUser(), request.getJoinDays(), request.getOrderCount(),
request.getCategory(), request.isCampaignActive());
if ("NONE".equals(couponType)) {
discountAmount = 0.0;
}
decision.setCouponType(couponType);
decision.setDiscountAmount(discountAmount);
decision.setCouponName(rules.couponName(couponType, discountAmount));
decision.setThreshold(rules.threshold(discountAmount));
decision.setValidDays(rules.validDays(couponType));
decision.setNeedManualReview(rules.needManualReview(request.isRiskFlag(), discountAmount));
decision.setStackable(rules.stackable(couponType, request.isHasUsedCoupon()));
decision.setRiskLevel(rules.riskLevel(request.isRiskFlag(), request.getOrderCount(), request.getJoinDays()));
buildReasons(request, decision, base, newUserBonus, loyaltyBonus, categoryBonus);
return decision;
}
private void buildReasons(CouponRequest request, CouponDecision decision, double base,
double newUserBonus, double loyaltyBonus, double categoryBonus) {
if ("NONE".equals(decision.getCouponType())) {
decision.addReason("订单金额与用户画像未命中任何发券策略");
return;
}
decision.addReason("会员等级 " + request.getMemberLevel() + " 命中基础满减 " + base + " 元");
if (newUserBonus > 0.0) {
decision.addReason("新客注册 " + request.getJoinDays() + " 天,叠加新客奖励 " + newUserBonus + " 元");
}
if (loyaltyBonus > 0.0) {
decision.addReason("历史下单 " + request.getOrderCount() + " 单,叠加忠诚奖励 " + loyaltyBonus + " 元");
}
if (categoryBonus > 0.0) {
decision.addReason("活动期 " + request.getCategory() + " 品类,叠加品类奖励 " + categoryBonus + " 元");
}
if (request.isRiskFlag()) {
decision.addReason("命中风控标记,需人工复核后再发放");
} else if (decision.isNeedManualReview()) {
decision.addReason("优惠金额达到 200 元复核线,转人工复核");
}
if (!decision.isStackable()) {
decision.addReason("该券不可与其他优惠叠加");
}
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.CouponDecision;
import com.github.paohaijiao.demo.model.CouponRequest;
import com.github.paohaijiao.demo.service.CouponService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 10 生产级样板:优惠券发放规则
* <p>
* 覆盖 6 组发券形态:新客首单、VIP 大额、大额满减、忠诚回馈、
* 风控拦截、未命中策略。
*/
public class CouponServiceTest {
private CouponService service;
@Before
public void setUp() {
CouponRules rules = JQuickJava.create()
.createApi(CouponRules.class, "demo/coupon.xml");
this.service = new CouponService(rules);
}
/**
* 新客首单:注册 2 天、订单 88 元 → 新客专享券 30 元,不可叠加,风控 MEDIUM
*/
@Test
public void shouldIssueNewUserCoupon() {
CouponRequest request = new CouponRequest();
request.setUserId("U-1001");
request.setMemberLevel("NORMAL");
request.setOrderAmount(88.0);
request.setOrderCount(0);
request.setCategory("APPAREL");
request.setJoinDays(2);
request.setLastOrderDays(0);
request.setAvgOrderAmount(0.0);
request.setNewUser(true);
request.setHasUsedCoupon(false);
request.setCampaignActive(false);
request.setStockLeft(1000);
request.setChannel("APP");
request.setRiskFlag(false);
CouponDecision decision = service.issue(request);
Assert.assertEquals("NEW_USER", decision.getCouponType());
Assert.assertEquals("新客专享券", decision.getCouponName());
Assert.assertEquals(30.0, decision.getDiscountAmount(), 0.001);
Assert.assertEquals(200.0, decision.getThreshold(), 0.001);
Assert.assertEquals(7, decision.getValidDays());
Assert.assertFalse(decision.isStackable());
Assert.assertFalse(decision.isNeedManualReview());
Assert.assertEquals("MEDIUM", decision.getRiskLevel());
Assert.assertTrue(decision.getReasons().contains("新客注册 2 天,叠加新客奖励 30.0 元"));
}
/**
* VIP 大额:订单 2500 元 → VIP 专享券 200 元,达复核线转人工
*/
@Test
public void shouldIssueVipExclusiveCoupon() {
CouponRequest request = new CouponRequest();
request.setUserId("U-1002");
request.setMemberLevel("VIP");
request.setOrderAmount(2500.0);
request.setOrderCount(15);
request.setCategory("DIGITAL");
request.setJoinDays(900);
request.setLastOrderDays(10);
request.setAvgOrderAmount(800.0);
request.setNewUser(false);
request.setHasUsedCoupon(false);
request.setCampaignActive(false);
request.setStockLeft(500);
request.setChannel("APP");
request.setRiskFlag(false);
CouponDecision decision = service.issue(request);
Assert.assertEquals("VIP_EXCLUSIVE", decision.getCouponType());
Assert.assertEquals("VIP专享券", decision.getCouponName());
Assert.assertEquals(200.0, decision.getDiscountAmount(), 0.001);
Assert.assertEquals(2000.0, decision.getThreshold(), 0.001);
Assert.assertEquals(30, decision.getValidDays());
Assert.assertTrue(decision.isStackable());
Assert.assertTrue(decision.isNeedManualReview());
Assert.assertEquals("LOW", decision.getRiskLevel());
Assert.assertTrue(decision.getReasons().contains("优惠金额达到 200 元复核线,转人工复核"));
}
/**
* 大额满减:SILVER、订单 1200 元 → 大额满减券 100 元
*/
@Test
public void shouldIssueLargeOrderCoupon() {
CouponRequest request = new CouponRequest();
request.setUserId("U-1003");
request.setMemberLevel("SILVER");
request.setOrderAmount(1200.0);
request.setOrderCount(8);
request.setCategory("GENERAL");
request.setJoinDays(200);
request.setLastOrderDays(20);
request.setAvgOrderAmount(400.0);
request.setNewUser(false);
request.setHasUsedCoupon(false);
request.setCampaignActive(false);
request.setStockLeft(800);
request.setChannel("WEB");
request.setRiskFlag(false);
CouponDecision decision = service.issue(request);
Assert.assertEquals("LARGE_ORDER", decision.getCouponType());
Assert.assertEquals("大额满减券", decision.getCouponName());
Assert.assertEquals(100.0, decision.getDiscountAmount(), 0.001);
Assert.assertEquals(1000.0, decision.getThreshold(), 0.001);
Assert.assertEquals(15, decision.getValidDays());
Assert.assertFalse(decision.isNeedManualReview());
Assert.assertEquals("LOW", decision.getRiskLevel());
}
/**
* 忠诚回馈:GOLD、35 单 + 活动期生鲜 → 基础 80 + 忠诚 20 + 品类 15 = 115 元
*/
@Test
public void shouldRewardLoyalUser() {
CouponRequest request = new CouponRequest();
request.setUserId("U-1004");
request.setMemberLevel("GOLD");
request.setOrderAmount(600.0);
request.setOrderCount(35);
request.setCategory("FRESH");
request.setJoinDays(1500);
request.setLastOrderDays(3);
request.setAvgOrderAmount(300.0);
request.setNewUser(false);
request.setHasUsedCoupon(false);
request.setCampaignActive(true);
request.setStockLeft(300);
request.setChannel("MINI");
request.setRiskFlag(false);
CouponDecision decision = service.issue(request);
Assert.assertEquals("LOYALTY", decision.getCouponType());
Assert.assertEquals("忠诚回馈券", decision.getCouponName());
Assert.assertEquals(115.0, decision.getDiscountAmount(), 0.001);
Assert.assertEquals(1000.0, decision.getThreshold(), 0.001);
Assert.assertEquals(10, decision.getValidDays());
Assert.assertTrue(decision.isStackable());
Assert.assertTrue(decision.getReasons().contains("历史下单 35 单,叠加忠诚奖励 20.0 元"));
Assert.assertTrue(decision.getReasons().contains("活动期 FRESH 品类,叠加品类奖励 15.0 元"));
}
/**
* 风控拦截:命中风控标记 + 已领券 → 需人工复核、不可叠加、风险 HIGH
*/
@Test
public void shouldHoldCouponForRiskReview() {
CouponRequest request = new CouponRequest();
request.setUserId("U-1005");
request.setMemberLevel("VIP");
request.setOrderAmount(3000.0);
request.setOrderCount(1);
request.setCategory("DIGITAL");
request.setJoinDays(1);
request.setLastOrderDays(0);
request.setAvgOrderAmount(0.0);
request.setNewUser(true);
request.setHasUsedCoupon(true);
request.setCampaignActive(false);
request.setStockLeft(10);
request.setChannel("APP");
request.setRiskFlag(true);
CouponDecision decision = service.issue(request);
Assert.assertEquals("NEW_USER", decision.getCouponType());
Assert.assertEquals(230.0, decision.getDiscountAmount(), 0.001);
Assert.assertEquals(2000.0, decision.getThreshold(), 0.001);
Assert.assertTrue(decision.isNeedManualReview());
Assert.assertFalse(decision.isStackable());
Assert.assertEquals("HIGH", decision.getRiskLevel());
Assert.assertTrue(decision.getReasons().contains("命中风控标记,需人工复核后再发放"));
}
/**
* 未命中策略:NORMAL、订单 120 元、非新客 → 无可用券
*/
@Test
public void shouldReturnNoneWhenNoStrategyMatched() {
CouponRequest request = new CouponRequest();
request.setUserId("U-1006");
request.setMemberLevel("NORMAL");
request.setOrderAmount(120.0);
request.setOrderCount(3);
request.setCategory("GENERAL");
request.setJoinDays(100);
request.setLastOrderDays(30);
request.setAvgOrderAmount(100.0);
request.setNewUser(false);
request.setHasUsedCoupon(false);
request.setCampaignActive(false);
request.setStockLeft(1000);
request.setChannel("WEB");
request.setRiskFlag(false);
CouponDecision decision = service.issue(request);
Assert.assertEquals("NONE", decision.getCouponType());
Assert.assertEquals("无可用券", decision.getCouponName());
Assert.assertEquals(0.0, decision.getDiscountAmount(), 0.001);
Assert.assertEquals(0.0, decision.getThreshold(), 0.001);
Assert.assertFalse(decision.isNeedManualReview());
Assert.assertEquals("LOW", decision.getRiskLevel());
Assert.assertTrue(decision.getReasons().contains("订单金额与用户画像未命中任何发券策略"));
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=CouponServiceTest
测试方法 构造的业务形态 关键断言/期望决策
shouldIssueNewUserCoupon NORMAL 新客:注册 2 天、订单 88 元、服饰品类 NEW_USER、新客专享券、优惠 30.0、门槛 200.0、7 天有效、不可叠加、无需复核、风险 MEDIUM
shouldIssueVipExclusiveCoupon VIP 老客:订单 2500 元、15 单、数码品类 VIP_EXCLUSIVE、VIP专享券、优惠 200.0、门槛 2000.0、30 天、可叠加、需人工复核、LOW
shouldIssueLargeOrderCoupon SILVER:订单 1200 元、8 单、普通品类 LARGE_ORDER、大额满减券、优惠 100.0、门槛 1000.0、15 天、无需复核、LOW
shouldRewardLoyalUser GOLD:订单 600 元、35 单、活动期生鲜 LOYALTY、忠诚回馈券、优惠 115.0(基础 80 + 忠诚 20 + 品类 15)、门槛 1000.0、10 天、可叠加
shouldHoldCouponForRiskReview VIP 新客:订单 3000 元、已领券、命中风控标记 NEW_USER、优惠 230.0(200+30)、门槛 2000.0、需人工复核、不可叠加、风险 HIGH
shouldReturnNoneWhenNoStrategyMatched NORMAL:订单 120 元、3 单、非新客、无活动 NONE、无可用券、优惠 0.0、门槛 0.0、无需复核、LOW、含未命中策略依据

USE-CASES.md 场景 10 收录的两组实测输出:

新客首单(注册 2 天、88 元):

券类型: NEW_USER 券名: 新客专享券 优惠: 30.0 门槛: 200.0
有效期: 7 天 可叠加: false 风控: MEDIUM

GOLD 忠诚会员活动期生鲜单(600 元、35 单):

券类型: LOYALTY 券名: 忠诚回馈券 优惠: 115.0(80 + 20 + 15)
门槛: 1000.0 有效期: 10 天 可叠加: true
  • 满减矩阵、三类奖励、7 档门槛、4 档有效期、复核线全部外置在 XML 中,大促改档运营改脚本即可,营销主程序无需发版。
  • 四段优惠金额由 4 个独立规则方法计算,couponType 用一条优先级 if-else 链唯一归类券种,新增营销活动只需新增方法与分支,不动既有口径。
  • Service 在 Java 侧做两处关键收口:券类型为 NONE 时把四段汇总金额统一清零,避免「不发券却算出优惠」;风控标记与 200 元复核线在规则侧前置判定,命中即挂起人工复核。
  • stackable 对「已领过本活动券」与「新客券」两条不可叠加规则分别正向判断;couponName 以金额是否不大于 0 兜底返回「无可用券」,均未使用脚本不支持的取反写法。
  • @Param 按名绑定让 couponType 的 7 个入参清晰可读,代理实例创建一次即可在大促高并发领券链路复用,底层为 ASM 调用链。
  • Java 8 即可运行、无重型规则平台依赖,可直接嵌入存量营销系统与信创环境。