Просмотр исходного кода

计算优惠使用商品券返回商品信息

qmj 1 неделя назад
Родитель
Сommit
21e5b4b9df

Разница между файлами не показана из-за своего большого размера
+ 13 - 0
.claude/homunculus/observations.jsonl


+ 168 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPay.java

@@ -0,0 +1,168 @@
+package com.ruoyi.app.utils.ezPay;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.ezPayCrypto.EzPayEncryptUtil;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ezPay 电子发票加值服务平台 客户端。
+ *
+ * <p>串接流程:业务参数组装 → PostData_ 加密 → Form Post(UTF-8) → JSON 回应。
+ * 对应官方手册 EZP_INVI_1.2.1(开立 / 作废 / 折让 / 查询)。
+ *
+ * <p>多商家:每个商家在 ezPay 申请独立的 HashKey / HashIV,调用时传入 {@link EzPayConfig}。
+ *
+ * <p>用法示例(即时开立 B2C 发票):
+ * <pre>{@code
+ * EzPayConfig cfg = new EzPayConfig("3482911", "商店HashKey32字节", "商店HashIV16字节");
+ * Map<String, Object> inv = new LinkedHashMap<>();
+ * inv.put("MerchantOrderNo", "ORD20260615001");     // 商店自订单号(同商店唯一)
+ * inv.put("Category", "B2C");                         // B2B=营业人 B2C=个人
+ * inv.put("BuyerName", "王小明");
+ * inv.put("PrintFlag", "Y");                          // 无载具/捐赠时必填 Y
+ * inv.put("TaxType", "1");                            // 1=应税
+ * inv.put("TaxRate", "5");
+ * inv.put("Amt", "490");                              // 销售额(未税)
+ * inv.put("TaxAmt", "10");                            // 税额
+ * inv.put("TotalAmt", "500");                         // 含税金额 = 销售额+税额
+ * inv.put("ItemName", "餐点A|餐点B");                 // 多品项用 | 分隔
+ * inv.put("ItemCount", "1|2");
+ * inv.put("ItemUnit", "個|個");
+ * inv.put("ItemPrice", "300|100");                    // B2C 为含税单价
+ * inv.put("ItemAmt", "300|200");
+ * JSONObject resp = ezPay.issueInvoice(EzPay.BASE_TEST, cfg, inv);
+ * // resp.result 含 InvoiceNumber / RandomNum / InvoiceTransNo ...
+ * }</pre>
+ *
+ * @author ruoyi
+ */
+@Component
+public class EzPay {
+
+    /** 正式环境根地址。 */
+    public static final String BASE_PROD = "https://inv.ezpay.com.tw";
+    /** 测试环境根地址。 */
+    public static final String BASE_TEST = "https://cinv.ezpay.com.tw";
+
+    // ===== 发票接口(INVI)=====
+    /** 开立发票 */
+    public static final String URL_ISSUE = "/Api/invoice_issue";
+    /** 触发开立(Status=0 等待触发 / Status=3 预约自动 时使用) */
+    public static final String URL_TOUCH_ISSUE = "/Api/invoice_touch_issue";
+    /** 作废发票 */
+    public static final String URL_INVALID = "/Api/invoice_invalid";
+    /** 开立折让 */
+    public static final String URL_ALLOWANCE_ISSUE = "/Api/allowance_issue";
+    /** 触发确认 / 取消折让 */
+    public static final String URL_ALLOWANCE_TOUCH = "/Api/allowance_touch_issue";
+    /** 作废折让 */
+    public static final String URL_ALLOWANCE_INVALID = "/Api/allowanceInvalid";
+    /** 查询发票 */
+    public static final String URL_SEARCH = "/Api/invoice_search";
+
+    // ===== 验证接口(BDV)=====
+    /** 手机条码验证 */
+    public static final String URL_CHECK_BARCODE = "/Api_inv_application/checkBarCode";
+    /** 捐赠码验证 */
+    public static final String URL_CHECK_LOVECODE = "/Api_inv_application/checkLoveCode";
+
+    /**
+     * 开立发票(即时开立 Status=1)。对应 POST /Api/invoice_issue(Version=1.5)。
+     *
+     * @param baseUrl {@link #BASE_PROD} 或 {@link #BASE_TEST}
+     * @param config  商店配置
+     * @param invoice 发票业务参数(<b>不要</b>带 RespondType / Version / TimeStamp / Status,
+     *                由本方法自动补充;其余如 Category / BuyerName / Amt / TotalAmt / ItemName 等由调用方填)
+     * @return 回应 JSON,含 Status / Message / Result,Result 内有 InvoiceNumber / RandomNum 等
+     */
+    public JSONObject issueInvoice(String baseUrl, EzPayConfig config, Map<String, Object> invoice) throws Exception {
+        Map<String, Object> postData = new LinkedHashMap<>(invoice);
+        postData.put("RespondType", "JSON");
+        postData.put("Version", "1.5");
+        postData.put("Status", "1"); // 1=即时开立
+        return doPost(baseUrl + URL_ISSUE, config, postData);
+    }
+
+    /**
+     * 查询发票(按发票号 + 防伪随机码)。对应 POST /Api/invoice_search(Version=1.3)。
+     */
+    public JSONObject searchInvoice(String baseUrl, EzPayConfig config,
+                                    String invoiceNumber, String randomNum) throws Exception {
+        Map<String, Object> postData = new LinkedHashMap<>();
+        postData.put("RespondType", "JSON");
+        postData.put("Version", "1.3");
+        postData.put("SearchType", "0"); // 0=发票号+随机码;1=订单号+发票金额
+        postData.put("InvoiceNumber", invoiceNumber);
+        postData.put("RandomNum", randomNum);
+        return doPost(baseUrl + URL_SEARCH, config, postData);
+    }
+
+    /**
+     * 底层调用:补充 TimeStamp → http_build_query → PostData_ 加密 → Form Post → JSON。
+     *
+     * <p>作废 / 折让 / 触发开立等接口可直接复用本方法,调用方自行设置 RespondType / Version / 业务字段。
+     *
+     * @param fullUrl 完整请求地址(baseUrl + 接口路径常量)
+     * @param config  商店配置
+     * @param postData PostData_ 内含字段(不含 TimeStamp,本方法自动补;其余公共字段由调用方放进去)
+     * @return ezPay 回应 JSON
+     */
+    public JSONObject doPost(String fullUrl, EzPayConfig config, Map<String, Object> postData) throws Exception {
+        postData.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        String query = buildQuery(postData);
+        String postDataEncrypted = EzPayEncryptUtil.encrypt(query, config.getHashKey(), config.getHashIV());
+        String resp = postForm(fullUrl, config.getMerchantId(), postDataEncrypted);
+        return JSON.parseObject(resp);
+    }
+
+    /**
+     * 模拟 PHP http_build_query:key、value 均做 URL 编码,用 & 连接。
+     * null 值跳过(ezPay 字段缺省时传空字符串而非 null)。
+     */
+    private static String buildQuery(Map<String, Object> params) {
+        StringBuilder sb = new StringBuilder();
+        for (Map.Entry<String, Object> e : params.entrySet()) {
+            if (e.getValue() == null) {
+                continue;
+            }
+            if (!sb.isEmpty()) {
+                sb.append('&');
+            }
+            sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
+                    .append('=')
+                    .append(URLEncoder.encode(String.valueOf(e.getValue()), StandardCharsets.UTF_8));
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 以标准 Form Post 发送 MerchantID_ + PostData_ 两个参数。
+     */
+    private static String postForm(String url, String merchantId, String postData) throws Exception {
+        List<NameValuePair> params = new ArrayList<>();
+        params.add(new BasicNameValuePair("MerchantID_", merchantId));
+        params.add(new BasicNameValuePair("PostData_", postData));
+        HttpPost post = new HttpPost(url);
+        post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.createDefault();
+             CloseableHttpResponse res = client.execute(post)) {
+            return EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
+        }
+    }
+}

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPayConfig.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.utils.ezPay;
+
+import lombok.Data;
+
+/**
+ * ezPay 商店配置(运行时传入,支持多商家)。
+ *
+ * <p>每个商家在 ezPay 平台申请商店后会拿到一组专属金钥,由业务层从数据库读取后构造本对象传入。
+ * 申请入口:测试环境 https://cinv.ezpay.com.tw/ | 正式环境 https://inv.ezpay.com.tw/
+ *
+ * @author ruoyi
+ */
+@Data
+public class EzPayConfig {
+
+    /** 商店代号(发票 / 验证接口用,对应请求参数 MerchantID_)。 */
+    private String merchantId;
+
+    /** 会员编号(字轨接口用,对应请求参数 CompanyID_);不用字轨功能可不填。 */
+    private String companyId;
+
+    /** 商店 HashKey(固定 32 字节)。 */
+    private String hashKey;
+
+    /** 商店 HashIV(固定 16 字节)。 */
+    private String hashIV;
+
+    public EzPayConfig() {
+    }
+
+    public EzPayConfig(String merchantId, String hashKey, String hashIV) {
+        this.merchantId = merchantId;
+        this.hashKey = hashKey;
+        this.hashIV = hashIV;
+    }
+}

+ 219 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPayCrypto/EzPayEncryptUtil.java

@@ -0,0 +1,219 @@
+package com.ruoyi.app.utils.ezPayCrypto;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * ezPay 电子发票加值服务平台 加密 / 签名工具。
+ *
+ * <p>对应官方手册「附件一 PostData_ 加密方法」「附件二 CheckCode 产生规则」的 PHP / C# 示例。
+ *
+ * <p><b>关键点</b>:ezPay 要求 PKCS7 填充块为 <b>32 字节</b>(非 AES 标准的 16),再用
+ * AES-256-CBC(NoPadding) 加密。因为 32 是 16 的倍数,填充后的数据对标准 AES-256-CBC 合法,
+ * 无需 BouncyCastle,标准 javax.crypto 即可。
+ *
+ * <ul>
+ *   <li>HashKey 固定 32 字节(→ AES-256),HashIV 固定 16 字节。</li>
+ *   <li>加密结果输出小写 hex,即请求里的 PostData_。</li>
+ * </ul>
+ *
+ * <p>自测:运行 {@link #main} 会用官方手册里的示例数据验证 CheckValue / CheckCode /
+ * 加解密 round-trip,全部 PASS 才算实现正确。
+ *
+ * @author ruoyi
+ */
+public class EzPayEncryptUtil {
+
+    /** ezPay 特殊的 PKCS7 填充块大小(字节),官方手册固定为 32。 */
+    private static final int BLOCK_SIZE = 32;
+
+    private static final String TRANSFORMATION = "AES/CBC/NoPadding";
+
+    private EzPayEncryptUtil() {
+    }
+
+    /**
+     * AES-256-CBC 加密 → 小写 hex(用于 PostData_)。
+     *
+     * @param data    明文,参数已按 http_build_query 形式拼成 {@code a=1&b=2}
+     * @param hashKey 商店 HashKey(固定 32 字节)
+     * @param hashIV  商店 HashIV(固定 16 字节)
+     * @return 小写 hex 密文,放入请求的 PostData_
+     */
+    public static String encrypt(String data, String hashKey, String hashIV) {
+        try {
+            byte[] padded = pkcs7Pad(data.getBytes(StandardCharsets.UTF_8), BLOCK_SIZE);
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.ENCRYPT_MODE,
+                    new SecretKeySpec(hashKey.getBytes(StandardCharsets.UTF_8), "AES"),
+                    new IvParameterSpec(hashIV.getBytes(StandardCharsets.UTF_8)));
+            byte[] encrypted = cipher.doFinal(padded);
+            return HexFormat.of().formatHex(encrypted);
+        } catch (Exception e) {
+            throw new RuntimeException("ezPay PostData_ 加密失败", e);
+        }
+    }
+
+    /**
+     * AES-256-CBC 解密(用于解密回应里的 Result 字段)。
+     *
+     * @param hexData 小写 hex 密文
+     * @return 解密后的明文(通常是 a=1&b=2 形式或 JSON 字符串)
+     */
+    public static String decrypt(String hexData, String hashKey, String hashIV) {
+        try {
+            byte[] bytes = HexFormat.of().parseHex(hexData);
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.DECRYPT_MODE,
+                    new SecretKeySpec(hashKey.getBytes(StandardCharsets.UTF_8), "AES"),
+                    new IvParameterSpec(hashIV.getBytes(StandardCharsets.UTF_8)));
+            byte[] decrypted = cipher.doFinal(bytes);
+            return new String(pkcs7Unpad(decrypted), StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new RuntimeException("ezPay Result 解密失败", e);
+        }
+    }
+
+    /**
+     * 生成请求 CheckValue:SHA256("HashKey={key}&{postDataHex}&HashIV={iv}") 转大写。
+     *
+     * <p>仅 BDV 验证类接口(checkBarCode / checkLoveCode)请求时需要带 CheckValue;
+     * 发票类接口(issue / invalid / search ...)请求不带 CheckValue,只发 MerchantID_ + PostData_。
+     *
+     * <p><b>文档勘误提醒</b>:BDV 手册附件三把一个「回应 CheckCode」的示例值误标在 CheckValue 章节,
+     * 故无法用文档示例验证本方法。本方法严格按附件三<b>文字描述</b>实现(HashKey 在前、HashIV 在后)。
+     * 实际接入 checkBarCode 若平台返回 CheckValue 校验失败,可改为 HashIV 在前重试
+     * (即与 {@link #genCheckCodeRaw} 同序,二者只差首尾 Key/IV 顺序)。
+     */
+    public static String genCheckValue(String postDataHex, String hashKey, String hashIV) {
+        String raw = "HashKey=" + hashKey + "&" + postDataHex + "&HashIV=" + hashIV;
+        return sha256Upper(raw);
+    }
+
+    /**
+     * 生成回应 CheckCode:将指定字段按 A~Z 排序,
+     * SHA256("HashIV={iv}&{k1=v1&k2=v2...}&HashKey={key}") 转大写。
+     *
+     * <p>用于校验回应确实来自 ezPay 平台。各接口参与校验的字段不同:
+     * <ul>
+     *   <li>发票开立 / 触发 / 查询:InvoiceTransNo, MerchantID, MerchantOrderNo, RandomNum, TotalAmt</li>
+     *   <li>作废发票:MerchantID, InvoiceNumber, CreateTime(参考手册各章节「附件二」)</li>
+     *   <li>折让:MerchantID, AllowanceNo, MerchantOrderNo, AllowanceAmt, RemainAmt</li>
+     * </ul>
+     * 调用方按接口把对应字段塞进 {@code fields} 即可,键名须与官方一致。
+     *
+     * @param fields 回应中参与校验的字段
+     */
+    public static String genCheckCode(Map<String, String> fields, String hashKey, String hashIV) {
+        TreeMap<String, String> sorted = new TreeMap<>(fields);
+        StringBuilder sb = new StringBuilder("HashIV=").append(hashIV).append('&');
+        for (Map.Entry<String, String> e : sorted.entrySet()) {
+            sb.append(e.getKey()).append('=').append(e.getValue()).append('&');
+        }
+        sb.append("HashKey=").append(hashKey);
+        return sha256Upper(sb.toString());
+    }
+
+    /**
+     * 生成回应 CheckCode(BDV 验证接口风格):直接用一段原始串(如回应里的 Result 密文 hex)
+     * 拼接,SHA256("HashIV={iv}&{raw}&HashKey={key}") 转大写。
+     *
+     * <p>BDV 接口(checkBarCode / checkLoveCode)的回应 CheckCode 以解密前的 Result 密文 hex
+     * 作为唯一参与串;与发票接口(INVI)的多字段排序 CheckCode 不同,故单独提供。
+     */
+    public static String genCheckCodeRaw(String raw, String hashKey, String hashIV) {
+        return sha256Upper("HashIV=" + hashIV + "&" + raw + "&HashKey=" + hashKey);
+    }
+
+    private static String sha256Upper(String raw) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
+            return HexFormat.of().formatHex(digest).toUpperCase();
+        } catch (Exception e) {
+            throw new RuntimeException("SHA256 计算失败", e);
+        }
+    }
+
+    /** PKCS7 填充到 blockSize 的整数倍。 */
+    private static byte[] pkcs7Pad(byte[] data, int blockSize) {
+        int pad = blockSize - (data.length % blockSize);
+        byte[] result = new byte[data.length + pad];
+        System.arraycopy(data, 0, result, 0, data.length);
+        for (int i = data.length; i < result.length; i++) {
+            result[i] = (byte) pad;
+        }
+        return result;
+    }
+
+    /** 去除 PKCS7 填充。 */
+    private static byte[] pkcs7Unpad(byte[] data) {
+        int pad = data[data.length - 1] & 0xFF;
+        return Arrays.copyOf(data, data.length - pad);
+    }
+
+    // ===================== 自测:用官方手册示例数据验证 =====================
+
+    public static void main(String[] args) {
+        int pass = 0, fail = 0;
+
+        // 1) CheckCode:INVI 手册附件二示例
+        //    字段 InvoiceTransNo/MerchantID/MerchantOrderNo/RandomNum/TotalAmt
+        //    HashKey=abcdefghijklmnopqrstuvwxyzabcdef, HashIV=1234567891234567
+        //    期望 303AB800650B724733B5D91CBCE075D9EA09E4CDE9CD33461D45F07D5EC7EECB
+        Map<String, String> checkCodeFields = new java.util.HashMap<>();
+        checkCodeFields.put("InvoiceTransNo", "14061313541640927");
+        checkCodeFields.put("MerchantID", "3622183");
+        checkCodeFields.put("MerchantOrderNo", "201409170000001");
+        checkCodeFields.put("RandomNum", "0142");
+        checkCodeFields.put("TotalAmt", "500");
+        String cc = genCheckCode(checkCodeFields, "abcdefghijklmnopqrstuvwxyzabcdef", "1234567891234567");
+        if ("303AB800650B724733B5D91CBCE075D9EA09E4CDE9CD33461D45F07D5EC7EECB".equals(cc)) {
+            System.out.println("[PASS] CheckCode"); pass++;
+        } else {
+            System.out.println("[FAIL] CheckCode  got=" + cc); fail++;
+        }
+
+        // 2) BDV 回应 CheckCode(Result 密文单字段):手册 PAGE 10 示例
+        //    HashIV=SdsatSdXm1vH7N3T & Result hex & HashKey=rsBjnIvMG3VMMWsEynTK3PWiGccHuYiV
+        //    期望 052E06C3720642DE306C1C33F921713EA4BA6DB9970C78F6C98952D4E69E85F7
+        //    注:BDV 附件三把这个 CheckCode 值误标为 CheckValue,实际是回应 CheckCode(HashIV 在前)。
+        String resultHex = "0628256c389824ce257c6e8796707aa3bc803d2245f4894261dbae64ff9a51f34f519b79c3fd8892157a8703779ab7639c62c3d078e7c8a09006a27f4f8aefbb";
+        String cc2 = genCheckCodeRaw(resultHex, "rsBjnIvMG3VMMWsEynTK3PWiGccHuYiV", "SdsatSdXm1vH7N3T");
+        if ("052E06C3720642DE306C1C33F921713EA4BA6DB9970C78F6C98952D4E69E85F7".equals(cc2)) {
+            System.out.println("[PASS] CheckCode(BDV/Result)"); pass++;
+        } else {
+            System.out.println("[FAIL] CheckCode(BDV) got=" + cc2); fail++;
+        }
+
+        // 3) 加解密 round-trip:用 32 字节 key + 16 字节 iv
+        String key = "abcdefghijklmnopqrstuvwxyzabcdef";
+        String iv = "1234567891234567";
+        String plain = "RespondType=JSON&Version=1.0&TimeStamp=1444963784";
+        String enc = encrypt(plain, key, iv);
+        String dec = decrypt(enc, key, iv);
+        if (plain.equals(dec)) {
+            System.out.println("[PASS] encrypt/decrypt round-trip"); pass++;
+        } else {
+            System.out.println("[FAIL] round-trip  dec=" + dec); fail++;
+        }
+
+        // 4) 边界:明文长度正好是 32 倍数时,应补一整块(32)
+        String plain2 = "0123456789ABCDEF0123456789ABCDEF"; // 32 字节
+        String dec2 = decrypt(encrypt(plain2, key, iv), key, iv);
+        if (plain2.equals(dec2)) {
+            System.out.println("[PASS] PKCS7 满块补整块"); pass++;
+        } else {
+            System.out.println("[FAIL] 满块补整块 dec=" + dec2); fail++;
+        }
+
+        System.out.println("\n结果: " + pass + " passed, " + fail + " failed");
+    }
+}

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java

@@ -22,6 +22,7 @@ import lombok.Data;
  * - couponReduce      优惠券减免金额
  * - couponConflict    优惠券是否与促销冲突
  * - conflictNote      冲突说明
+ * - couponProductItems 商品券参与的商品明细(名称/原价/优惠后价)
  * - details           优惠明细列表
  * - availableCoupons  可用优惠券列表
  */
@@ -71,6 +72,9 @@ public class PromotionCalcResponse {
     /** 优惠券批次ID(使用券时返回, 对应 promotion_coupon_batch.id) */
     private Long couponBatchId;
 
+    /** 商品券参与优惠的商品明细(仅商品券生效时返回, 含名称/原价/优惠后价) */
+    private List<LineItem> couponProductItems;
+
     /**
      * 路径结果
      */

+ 59 - 14
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java

@@ -200,6 +200,8 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         Boolean couponConflict = null;
         String conflictNote = null;
         PromotionCouponBatch batch = null;
+        // 商品券生效时, 记录参与优惠的商品明细(名称/原价/优惠后价)
+        List<Map<String, Object>> couponProductItems = null;
 
         if (couponId != null)
         {
@@ -225,7 +227,9 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                         {
                             couponConflict = true;
                             conflictNote = "互斥券不可与满减/折扣叠加,选择此券将取消促销优惠";
-                            couponReduce = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap);
+                            CouponCalcResult mutexResult = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap, nameMap);
+                            couponReduce = mutexResult.reduce;
+                            couponProductItems = mutexResult.items;
                             afterPromotion = originalAmount;
                             promotionReduce = BigDecimal.ZERO;
                             newCustomerReduce = BigDecimal.ZERO;
@@ -259,8 +263,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
 
                                 if (couponConflict == null)
                                 {
-                                    couponReduce = calcCouponReduce(couponRule, batch, itemsWithPrice,
-                                            baseForCoupon, priceMap);
+                                    CouponCalcResult normalResult = calcCouponReduce(couponRule, batch, itemsWithPrice,
+                                            baseForCoupon, priceMap, nameMap);
+                                    couponReduce = normalResult.reduce;
+                                    couponProductItems = normalResult.items;
                                 }
                             }
                         }
@@ -310,6 +316,12 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         {
             response.setCouponBatchId(batch.getId());
         }
+        // 商品券: 返回参与优惠的商品明细(名称/原价/优惠后价)
+        if (couponProductItems != null && !couponProductItems.isEmpty())
+        {
+            response.setCouponProductItems(couponProductItems.stream()
+                    .map(this::toLineItem).collect(Collectors.toList()));
+        }
 
         // ---- 10. 明细列表 ----
         List<PromotionDetail> details = new ArrayList<>();
@@ -727,49 +739,82 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
     }
 
     /**
-     * 计算单张券的减免金额
+     * 券减免计算结果: reduce=减免金额, items=商品券参与的商品明细(仅商品券有)
+     */
+    private static class CouponCalcResult
+    {
+        BigDecimal reduce = BigDecimal.ZERO;
+        List<Map<String, Object>> items = new ArrayList<>();
+    }
+
+    /**
+     * 计算单张券的减免金额; 商品券(couponType=2)同时返回参与商品明细(名称/原价/优惠后价)
      */
-    private BigDecimal calcCouponReduce(PromotionCouponRule couponRule, PromotionCouponBatch batch,
-                                        List<Map<String, Object>> itemsWithPrice,
-                                        BigDecimal baseAmount, Map<Long, BigDecimal> priceMap)
+    private CouponCalcResult calcCouponReduce(PromotionCouponRule couponRule, PromotionCouponBatch batch,
+                                              List<Map<String, Object>> itemsWithPrice,
+                                              BigDecimal baseAmount, Map<Long, BigDecimal> priceMap,
+                                              Map<Long, String> nameMap)
     {
+        CouponCalcResult result = new CouponCalcResult();
         if (batch.getCouponType() != null && batch.getCouponType() == 2)
         {
             if (couponRule.getProductId() != null)
             {
-                BigDecimal productPrice = priceMap.getOrDefault(couponRule.getProductId(), BigDecimal.ZERO);
+                Long pid = couponRule.getProductId();
+                BigDecimal productPrice = priceMap.getOrDefault(pid, BigDecimal.ZERO);
                 int quantity = 0;
                 for (Map<String, Object> item : itemsWithPrice)
                 {
-                    if (couponRule.getProductId().equals(toLong(item.get("productId"))))
+                    if (pid.equals(toLong(item.get("productId"))))
                     {
                         quantity = toInt(item.get("quantity"));
                         break;
                     }
                 }
                 BigDecimal totalProductPrice = productPrice.multiply(BigDecimal.valueOf(quantity));
+                BigDecimal finalLineTotal = totalProductPrice;
                 if (couponRule.getDiscountRate() != null)
                 {
                     BigDecimal reduction = totalProductPrice.multiply(BigDecimal.ONE.subtract(couponRule.getDiscountRate()));
-                    return reduction.max(BigDecimal.ZERO).min(totalProductPrice)
+                    result.reduce = reduction.max(BigDecimal.ZERO).min(totalProductPrice)
+                            .setScale(0, RoundingMode.HALF_UP);
+                    finalLineTotal = totalProductPrice.subtract(result.reduce).max(BigDecimal.ZERO)
                             .setScale(0, RoundingMode.HALF_UP);
                 }
                 else if (couponRule.getAmount() != null)
                 {
-                    return couponRule.getAmount().min(totalProductPrice)
+                    result.reduce = couponRule.getAmount().min(totalProductPrice)
                             .setScale(0, RoundingMode.HALF_UP);
+                    finalLineTotal = totalProductPrice.subtract(result.reduce).max(BigDecimal.ZERO)
+                            .setScale(0, RoundingMode.HALF_UP);
+                }
+                // 购物车含该商品才产出明细
+                if (quantity > 0)
+                {
+                    Map<String, Object> lineItem = new LinkedHashMap<>();
+                    lineItem.put("productId", pid);
+                    lineItem.put("quantity", quantity);
+                    lineItem.put("unitPrice", productPrice);
+                    lineItem.put("name", nameMap.getOrDefault(pid, ""));
+                    lineItem.put("originalLineTotal", totalProductPrice.setScale(0, RoundingMode.HALF_UP));
+                    lineItem.put("finalLineTotal", finalLineTotal);
+                    if (couponRule.getDiscountRate() != null)
+                    {
+                        lineItem.put("discountRate", couponRule.getDiscountRate());
+                    }
+                    result.items.add(lineItem);
                 }
             }
-            return BigDecimal.ZERO;
+            return result;
         }
         else
         {
             if (couponRule.getAmount() != null)
             {
-                return couponRule.getAmount().min(baseAmount).setScale(0, RoundingMode.HALF_UP);
+                result.reduce = couponRule.getAmount().min(baseAmount).setScale(0, RoundingMode.HALF_UP);
             }
+            return result;
         }
-        return BigDecimal.ZERO;
     }
 
     /**

Некоторые файлы не были показаны из-за большого количества измененных файлов