Skip to content

Case 10: Coupon issuance rules

This case corresponds to Scenario 10: Coupon issuance rules in the JQuick-Java repository’s USE-CASES.md. It belongs to the marketing / e-commerce domain and solves the automated coupon issuance decision at order or claim time: “base tiered discount + new-user/loyalty/category rewards → coupon type classification → coupon name / threshold / validity / stackability → risk control and manual review”.

An e-commerce platform needs to issue coupons in real time based on user profile and the current order during daily operations and big promotions. The coupon request carries: user ID, membership level (NORMAL/SILVER/GOLD/VIP), current order amount, historical order count, product category (DIGITAL/FRESH/APPAREL/GENERAL), days since registration, days since last order, historical average order value, whether new user, whether already claimed a coupon from this campaign, whether the campaign is active, coupon stock, acquisition channel (APP/WEB/MINI), and a risk flag.

The discount amount is computed by summing four independent parts: the base tiered discount uses a matrix of 3 order amount tiers (≥1000/≥500/≥200) × 4 membership levels (VIP/GOLD/SILVER/NORMAL), with a maximum of 200 CNY; the new-user reward adds 30 CNY when registered within 7 days; the loyalty reward adds 50 CNY for 50+ orders and VIP/GOLD, or 20 CNY for 20+ orders; the category reward adds 15 CNY for FRESH during campaigns, or 10 CNY for DIGITAL.

Coupon type is uniquely classified by business priority: new-user coupon (NEW_USER) takes precedence over VIP exclusive (VIP_EXCLUSIVE), large-order coupon (LARGE_ORDER), loyalty coupon (LOYALTY), category coupon (CATEGORY), and standard order coupon (ORDER); if none match, the type is NONE. Once the type is determined, it maps to the Chinese coupon name, a corresponding usage threshold (7 tiers from “spend 2000 save 200” down to “spend 100 save 10”), validity days (new-user 7 days, VIP 30 days, large-order 15 days, others 10 days), and whether it can be stacked (not stackable when already claimed or for new-user coupons).

The risk side produces two parallel conclusions: a manual review is required when the risk flag is hit or the discount amount reaches 200 CNY; the risk level is HIGH when the risk flag is set, MEDIUM when registered less than 3 days with no orders, and LOW otherwise. When no strategy is matched, the coupon type is NONE and the Service clears the discount amount to zero. Scenario 10 covers 6 profiles: new-user first order 30 CNY coupon, VIP 2500 CNY order 200 CNY coupon routed to manual review, SILVER large-order 100 CNY, GOLD loyal member campaign FRESH order 115 CNY, risk-flag account 230 CNY coupon held, and 120 CNY standard order with no coupon.

  • The tiered discount matrix (3 order tiers × 4 membership levels) and the three reward types (new-user, loyalty, category) are scattered across multiple campaign codebases; the same user can receive inconsistent amounts from different entry points.
  • Coupon types follow a strict business priority (new-user > VIP exclusive > large-order > loyalty > category > standard); hard-coded if-else branches can easily issue the wrong coupon type if the branch order changes.
  • Usage thresholds have 7 tiers and validity periods have 4 tiers, both adjusted frequently during big promotions; every change requires a scheduled deployment, and operations cannot self-serve.
  • Risk flag, the 200 CNY manual review line, and non-stackability rules are coupled with the main coupon flow; upgrading risk strategy requires changing core marketing code.
  • After summing independent rewards, an order that matches no strategy can still carry residual reward amounts, lacking a unified zeroing clamp.
  • Marketing systems are mostly legacy JDK 8 or IT-innovation environments, and teams do not want to introduce a heavy rule platform for coupon issuance.

This case uses the standard “six-piece set”: the input model carries the user and order snapshot, the decision result model carries coupon type, amount, threshold, and risk conclusions, the rule interface declares 11 rule methods, the XML script externalizes the tiered discount matrix, reward policies, and all thresholds, the Service handles four-part amount aggregation, NONE zeroing, and issuance reason assembly, and JUnit regresses with 6 coupon profiles.

Layer Responsibility Files
Domain model Coupon request, coupon decision result CouponRequest.java, CouponDecision.java
Rule set & XML 11 methods covering base discount + 3 rewards + coupon type/name/threshold/validity/risk CouponRules.java, coupon.xml
Orchestration service Four-part discount amount aggregation, unmatched zeroing, review and stackability determination, issuance reason assembly CouponService.java
Tests 6 coupon profiles (new-user / VIP / large-order / loyalty / risk / unmatched) CouponServiceTest.java
Rule method Policy Output
baseDiscount Order ≥1000: VIP 200/GOLD 150/SILVER 100/others 60; ≥500: 100/80/50/30; ≥200: VIP 40/others 20; otherwise 0 0-200 CNY
newUserBonus New user and registered ≤7 days +30, otherwise 0 0/30 CNY
loyaltyBonus Orders ≥50 and VIP/GOLD +50; ≥20 orders +20; otherwise 0 0/20/50 CNY
categoryBonus Campaign FRESH +15, DIGITAL +10, otherwise 0 0/10/15 CNY
couponType New-user > VIP exclusive > large-order > loyalty > FRESH category > standard order > none NEW_USER/VIP_EXCLUSIVE/LARGE_ORDER/LOYALTY/CATEGORY/ORDER/NONE
couponName Amount ≤0 → “无可用券”, otherwise map by coupon type Chinese coupon name
threshold Amount ≥200→2000 / ≥150→1500 / ≥100→1000 / ≥60→600 / ≥40→400 / ≥20→200 / ≥10→100 / otherwise 0 Threshold (CNY)
validDays NEW_USER 7 / VIP_EXCLUSIVE 30 / LARGE_ORDER 15 / others 10 Validity (days)
needManualReview Risk flag or discount ≥200 CNY → manual review true / false
stackable Already claimed or new-user coupon → not stackable, otherwise stackable true / false
riskLevel Risk flag → HIGH; registered <3 days and no orders → MEDIUM; otherwise LOW HIGH/MEDIUM/LOW

Place the six files into a Maven project following the repository’s original package structure: the two models go into src/test/java/com/github/paohaijiao/demo/model/, the rule interface and test go into the com.github.paohaijiao.demo package, the orchestration service goes into com.github.paohaijiao.demo.service, and the XML script goes into src/test/resources/demo/coupon.xml; keep all package names exactly as in the source. Run mvn test -Dtest=CouponServiceTest at the project root to execute.

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("订单金额与用户画像未命中任何发券策略"));
}
}

Run at the project root:

Terminal window
mvn test -Dtest=CouponServiceTest
Test method Business profile constructed Key assertions / expected decision
shouldIssueNewUserCoupon NORMAL new user: registered 2 days, order 88 CNY, APPAREL category NEW_USER, new-user exclusive coupon, discount 30.0, threshold 200.0, 7 days, not stackable, no review, risk MEDIUM
shouldIssueVipExclusiveCoupon VIP returning customer: order 2500 CNY, 15 orders, DIGITAL category VIP_EXCLUSIVE, VIP exclusive coupon, discount 200.0, threshold 2000.0, 30 days, stackable, manual review required, LOW
shouldIssueLargeOrderCoupon SILVER: order 1200 CNY, 8 orders, GENERAL category LARGE_ORDER, large-order coupon, discount 100.0, threshold 1000.0, 15 days, no review, LOW
shouldRewardLoyalUser GOLD: order 600 CNY, 35 orders, campaign FRESH LOYALTY, loyalty coupon, discount 115.0 (base 80 + loyalty 20 + category 15), threshold 1000.0, 10 days, stackable
shouldHoldCouponForRiskReview VIP new user: order 3000 CNY, already claimed, risk flag hit NEW_USER, discount 230.0 (200+30), threshold 2000.0, manual review required, not stackable, risk HIGH
shouldReturnNoneWhenNoStrategyMatched NORMAL: order 120 CNY, 3 orders, not new user, no campaign NONE, no available coupon, discount 0.0, threshold 0.0, no review, LOW, includes unmatched strategy reason

Two verified outputs recorded in USE-CASES.md Scenario 10:

New-user first order (registered 2 days, 88 CNY):

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

GOLD loyal member campaign FRESH order (600 CNY, 35 orders):

券类型: LOYALTY 券名: 忠诚回馈券 优惠: 115.0(80 + 20 + 15)
门槛: 1000.0 有效期: 10 天 可叠加: true
  • The tiered discount matrix, three reward types, 7 usage thresholds, 4 validity tiers, and the review line are all externalized in XML; big promotion adjustments only require editing the script, with no redeployment of the marketing application.
  • The four-part discount amount is calculated by 4 independent rule methods; couponType uses a single priority if-else chain to classify coupon types uniquely, so adding a new campaign only requires adding a method and a branch without touching existing policies.
  • The Service performs two critical clamps on the Java side: when the coupon type is NONE, the four-part total is unified to zero, preventing “no coupon issued but discount still calculated”; the risk flag and 200 CNY review line are pre-checked in the rule side and routed to manual review when hit.
  • stackable separately checks the two non-stackable rules (already claimed and new-user coupon) using positive conditions; couponName falls back to “无可用券” when the amount is not greater than 0, without using the unsupported negation syntax.
  • @Param name binding keeps the 7 inputs of couponType clear and readable; the proxy instance is created once and reused in high-concurrency big promotion claim flows, backed by an ASM call chain.
  • Runs on Java 8 with no heavy rule platform dependency, and can be embedded directly into legacy marketing systems and IT-innovation environments.