Browse Source

refactor(payment): retire legacy OMG integration

qmj 1 month ago
parent
commit
8325405b1d
22 changed files with 575 additions and 742 deletions
  1. 1 1
      .specify/feature.json
  2. 4 10
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java
  3. 72 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgStoreCredentialVerifier.java
  4. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  5. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPositionInfo.java
  6. 0 188
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java
  7. 0 298
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java
  8. 0 37
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPayConfig.java
  9. 0 30
      ruoyi-admin/src/main/resources/application.yml
  10. 4 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java
  11. 83 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgStoreCredentialVerifierTest.java
  12. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java
  13. 0 105
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgCheckMacValueTest.java
  14. 0 61
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgPayTest.java
  15. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  16. 1 1
      specs/016-omg-payment/callback-reconcile.md
  17. 1 1
      specs/016-omg-payment/data-model.md
  18. 370 0
      specs/019-line-pay/app-integration.md
  19. 10 1
      specs/020-omg-payment-rebuild/plan.md
  20. 4 1
      specs/020-omg-payment-rebuild/spec.md
  21. 12 4
      specs/020-omg-payment-rebuild/tasks.md
  22. 9 0
      updatesql/sql.md

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
 {
-  "feature_directory": "specs/018-order-status-admin"
+  "feature_directory": "specs/019-line-pay"
 }
 }

+ 4 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java

@@ -1,7 +1,6 @@
 package com.ruoyi.app.mendian;
 package com.ruoyi.app.mendian;
 
 
-import com.ruoyi.app.utils.omg.OmgPay;
-import com.ruoyi.app.utils.omg.OmgPayConfig;
+import com.ruoyi.app.omgpay.OmgStoreCredentialVerifier;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -12,7 +11,6 @@ import com.ruoyi.system.domain.vo.PosStoreOmgVo;
 import com.ruoyi.system.service.IPosStoreOmgService;
 import com.ruoyi.system.service.IPosStoreOmgService;
 import jakarta.validation.Valid;
 import jakarta.validation.Valid;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -29,7 +27,7 @@ import java.util.Map;
  * 平台后台 - 门店 OMG(歐買尬) 支付凭证管理。
  * 平台后台 - 门店 OMG(歐買尬) 支付凭证管理。
  *
  *
  * <p>OMG 商店注册为线下人工;本接口负责申请状态推进、凭证录入与联网验证(QueryTradeInfo 探测)、
  * <p>OMG 商店注册为线下人工;本接口负责申请状态推进、凭证录入与联网验证(QueryTradeInfo 探测)、
- * 启用开关。联网验证放 Controller 层(避免 ruoyi-system 反向依赖 ruoyi-admin 的 OmgPay 工具类),
+ * 启用开关。联网验证放应用层(避免 ruoyi-system 反向依赖 ruoyi-admin),
  * Service 仅持久化。
  * Service 仅持久化。
  *
  *
  * @author ruoyi
  * @author ruoyi
@@ -43,10 +41,7 @@ public class PosStoreOmgController extends BaseController {
     private IPosStoreOmgService posStoreOmgService;
     private IPosStoreOmgService posStoreOmgService;
 
 
     @Autowired
     @Autowired
-    private OmgPay omgPay;
-
-    @Value("${omg.base-url}")
-    private String omgBaseUrl;
+    private OmgStoreCredentialVerifier credentialVerifier;
 
 
     /** 门店 OMG 凭证管理列表(分页 + 筛选)。 */
     /** 门店 OMG 凭证管理列表(分页 + 筛选)。 */
     @PreAuthorize("@ss.hasPermi('chanting:storeOmg:list')")
     @PreAuthorize("@ss.hasPermi('chanting:storeOmg:list')")
@@ -80,10 +75,9 @@ public class PosStoreOmgController extends BaseController {
     @Log(title = "OMG录入凭证", businessType = BusinessType.UPDATE)
     @Log(title = "OMG录入凭证", businessType = BusinessType.UPDATE)
     @PutMapping("/saveCredentials")
     @PutMapping("/saveCredentials")
     public AjaxResult saveCredentials(@RequestBody @Valid StoreOmgCredentialDto dto) {
     public AjaxResult saveCredentials(@RequestBody @Valid StoreOmgCredentialDto dto) {
-        OmgPayConfig cfg = new OmgPayConfig(dto.getMerchantId(), dto.getHashKey(), dto.getHashIv());
         Map<String, String> resp;
         Map<String, String> resp;
         try {
         try {
-            resp = omgPay.queryTrade(omgBaseUrl, cfg, "OMGVERIFY" + (System.currentTimeMillis() / 1000L));
+            resp = credentialVerifier.verify(dto.getMerchantId(), dto.getHashKey(), dto.getHashIv());
         } catch (Exception e) {
         } catch (Exception e) {
             posStoreOmgService.recordVerifyResult(dto.getStoreId(), "ERROR: " + msg(e));
             posStoreOmgService.recordVerifyResult(dto.getStoreId(), "ERROR: " + msg(e));
             return error("OMG 验证服务暂不可用,请稍后重试");
             return error("OMG 验证服务暂不可用,请稍后重试");

+ 72 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgStoreCredentialVerifier.java

@@ -0,0 +1,72 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Verifies one store's OMG credentials against the stage query endpoint.
+ * Credentials are used only to sign and verify this probe; this boundary never logs or persists them.
+ */
+@Component
+public class OmgStoreCredentialVerifier {
+    private final OmgPaymentQueryGateway gateway;
+    private final OmgQueryResponseParser parser;
+    private final OmgCheckMacSigner signer;
+    private final Clock clock;
+
+    public OmgStoreCredentialVerifier(OmgPaymentQueryGateway gateway,
+                                      OmgQueryResponseParser parser,
+                                      OmgCheckMacSigner signer) {
+        this(gateway, parser, signer, Clock.systemUTC());
+    }
+
+    OmgStoreCredentialVerifier(OmgPaymentQueryGateway gateway,
+                               OmgQueryResponseParser parser,
+                               OmgCheckMacSigner signer,
+                               Clock clock) {
+        this.gateway = gateway;
+        this.parser = parser;
+        this.signer = signer;
+        this.clock = clock;
+    }
+
+    public Map<String, String> verify(String merchantId, String hashKey, String hashIv) {
+        requirePresent("MerchantID", merchantId);
+        long epochSecond = clock.instant().getEpochSecond();
+        LinkedHashMap<String, String> request = new LinkedHashMap<>();
+        request.put("MerchantID", merchantId);
+        request.put("MerchantTradeNo", "OMGVERIFY" + epochSecond);
+        request.put("TimeStamp", String.valueOf(epochSecond));
+        request.put("CheckMacValue", signer.sign(request, hashKey, hashIv));
+        Map<String, String> response = parser.parse(gateway.query(request));
+        verifyResponse(response, request, hashKey, hashIv);
+        return response;
+    }
+
+    private void verifyResponse(Map<String, String> response, Map<String, String> request,
+                                String hashKey, String hashIv) {
+        String checkMacValue = response.get("CheckMacValue");
+        if (checkMacValue == null || !checkMacValue.matches("(?i)[0-9a-f]{64}")) {
+            throw new IllegalArgumentException("OMG credential verification signature is malformed");
+        }
+        LinkedHashMap<String, String> signingFields = new LinkedHashMap<>(response);
+        signingFields.remove("CheckMacValue");
+        String expected = signer.sign(signingFields, hashKey, hashIv);
+        if (!OmgPaymentNotifyService.secureEquals(expected, checkMacValue)) {
+            throw new IllegalArgumentException("OMG credential verification signature mismatch");
+        }
+        if (!request.get("MerchantID").equals(response.get("MerchantID"))
+                || !request.get("MerchantTradeNo").equals(response.get("MerchantTradeNo"))) {
+            throw new IllegalArgumentException("OMG credential verification identity mismatch");
+        }
+    }
+
+    private static void requirePresent(String name, String value) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(name + " is required");
+        }
+    }
+}

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -248,7 +248,7 @@ public class UserOrderController extends BaseController {
             }
             }
             posOrder.setDelryTime(item.getDelryTime());
             posOrder.setDelryTime(item.getDelryTime());
             posOrder.setFood(JSON.toJSONString(item.getFood()));
             posOrder.setFood(JSON.toJSONString(item.getFood()));
-            if("1".equals(input.getPaymentMethod()) || "2".equals(input.getPaymentMethod())){
+            if("1".equals(input.getPaymentMethod())){
                 posOrder.setCollectPayment("1");
                 posOrder.setCollectPayment("1");
             }
             }
             // 设置支付相关信息(从父订单继承)
             // 设置支付相关信息(从父订单继承)

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPositionInfo.java

@@ -31,7 +31,7 @@ public class OrderPositionInfo {
     /** 出餐状态*/
     /** 出餐状态*/
     private Long diningStatus;
     private Long diningStatus;
 
 
-    /** 支付类型:1 货到付款,2 VNPAY,3 LINE Pay,7 OMG */
+    /** 支付类型:1 货到付款,2 OMG,3 LINE Pay */
     private String payType;
     private String payType;
     /**
     /**
      * 骑手位置
      * 骑手位置

+ 0 - 188
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java

@@ -1,188 +0,0 @@
-package com.ruoyi.app.utils.omg;
-
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.util.Comparator;
-import java.util.Locale;
-import java.util.Map;
-import java.util.TreeMap;
-
-/**
- * OMG(歐買尬/FunPoint) CheckMacValue 签名工具(SHA256,EncryptType=1)。
- *
- * <p>与蓝新 NewebPay(AES-256 加密 TradeInfo + SHA256 TradeSha) <b>本质不同</b>:OMG 无 AES、无解密,
- * 请求与回调都是「明文参数 + 单个 CheckMacValue」。本类独立于 {@code newebpay} 包,绝不复用
- * {@code NewebPayEncryptUtil}。
- *
- * <p>算法(contracts/api.md §C4 / research D1;编码 pipeline 已用 ECPay 物流官方向量 MD5 ground truth 验证;
- * <b>但该向量字段全首字母大写,无法暴露排序大小写分歧——排序大小写由生产 QueryTradeInfo 响应实测另行锁定</b>):
- * <ol>
- *   <li>去掉 {@code CheckMacValue} 本身;其余所有参数(包括空值)按 key <b>字母序、大小写不敏感</b>
- *       ({@link String#CASE_INSENSITIVE_ORDER})升序。OMG 服务端同此;QueryTradeInfo 响应字段大小写
- *       混合,大小写敏感排序会分歧致验签失败(bugB,2026-08-12 生产 4 候选诊断 receivedCMV==ci_inc 坐实)。</li>
- *   <li>拼 {@code k1=v1&k2=v2...}。</li>
- *   <li>包夹:{@code HashKey={key}&{query}&HashIV={iv}}。</li>
- *   <li>.NET 风格 URL 编码:{@code URLEncoder.encode(整串)}({@code & = / :} 与空格→{@code +} 一并被编码,
- *       与 PHP/ECPay 一致)后把 {@code %2D %5F %2E %21 %2A %28 %29} 还原成 {@code - _ . ! * ( )}。</li>
- *   <li>整串转小写。</li>
- *   <li>SHA256 → hex 大写 = CheckMacValue。</li>
- * </ol>
- * 校验同理:对回调参数同算法重算,与回传 CheckMacValue 比对。
- *
- * @author ruoyi
- * @date 2026-07-29
- */
-public final class OmgCheckMacValue {
-
-    private OmgCheckMacValue() {
-    }
-
-    /**
-     * 生成 CheckMacValue(SHA256,OMG AIO EncryptType=1)。
-     *
-     * @param params  全部请求/回调参数(可含 CheckMacValue 与空值,会被剔除)
-     * @param hashKey 商店 HashKey
-     * @param hashIv  商店 HashIV
-     * @return CheckMacValue(64 位 hex 大写)
-     */
-    public static String generate(Map<String, String> params, String hashKey, String hashIv) {
-        return generate(params, hashKey, hashIv, "SHA-256");
-    }
-
-    /** 指定摘要算法生成(SHA-256 用于 OMG 生产;MD5 仅供自测对照 ECPay 旧版向量)。 */
-    static String generate(Map<String, String> params, String hashKey, String hashIv, String algorithm) {
-        // 大小写不敏感排序:String.CASE_INSENSITIVE_ORDER。OMG(ECPay 同源引擎)服务端按字母序不分大小写
-        // 排序算 CheckMacValue。QueryTradeInfo 响应字段大小写混合(AlipayID/MerchantID/TradeStatus 等
-        // 首字母大写 vs amount/auth_code/card4no/eci/gwsr/process_date/red_dan 等首字母小写),大小写敏感
-        // 排序会与 OMG 分歧致验签失败(bugB,2026-08-12 由生产 4 候选诊断 receivedCMV==ci_inc 坐实)。
-        // 出站请求参数全为首字母大写,两种排序结果相同,故对本签名无回归。
-        return generate(params, hashKey, hashIv, String.CASE_INSENSITIVE_ORDER, false, algorithm);
-    }
-
-    /**
-     * 可配置排序与空值策略的生成(诊断用:排查 QueryTradeInfo 响应验签分歧)。
-     *
-     * @param keyOrder     key 排序比较器(大小写敏感 {@code Comparator.naturalOrder()} /
-     *                     不敏感 {@code String.CASE_INSENSITIVE_ORDER})
-     * @param excludeEmpty true=排除空值字段;false=保留空值字段(以 {@code key=} 参与,默认行为)
-     */
-    static String generate(Map<String, String> params, String hashKey, String hashIv,
-                           Comparator<String> keyOrder, boolean excludeEmpty) {
-        return generate(params, hashKey, hashIv, keyOrder, excludeEmpty, "SHA-256");
-    }
-
-    private static String generate(Map<String, String> params, String hashKey, String hashIv,
-                                   Comparator<String> keyOrder, boolean excludeEmpty, String algorithm) {
-        // 1-2. 去 CheckMacValue、去 null/空值(可选)、按 keyOrder 排序
-        TreeMap<String, String> sorted = new TreeMap<>(keyOrder);
-        if (params != null) {
-            for (Map.Entry<String, String> e : params.entrySet()) {
-                String k = e.getKey();
-                if (k == null || "CheckMacValue".equals(k)) {
-                    continue;
-                }
-                String v = e.getValue();
-                if (v == null) {
-                    continue;
-                }
-                if (excludeEmpty && v.isEmpty()) {
-                    continue;
-                }
-                sorted.put(k, v);
-            }
-        }
-        StringBuilder query = new StringBuilder();
-        for (Map.Entry<String, String> e : sorted.entrySet()) {
-            if (query.length() > 0) {
-                query.append('&');
-            }
-            query.append(e.getKey()).append('=').append(e.getValue());
-        }
-        // 3. 包夹 HashKey/HashIV
-        String raw = "HashKey=" + hashKey + "&" + query + "&HashIV=" + hashIv;
-        // 4-6. .NET 风格 URLEncode → 转小写 → 摘要 hex 大写
-        return hashHexUpper(dotNetUrlEncode(raw).toLowerCase(Locale.ROOT), algorithm);
-    }
-
-    /**
-     * 校验回调签名:对参数(含回传 CheckMacValue)重算并比对。
-     *
-     * @return true=验签通过
-     */
-    public static boolean verify(Map<String, String> params, String hashKey, String hashIv) {
-        if (params == null) {
-            return false;
-        }
-        String received = params.get("CheckMacValue");
-        if (received == null || !received.matches("[0-9A-Fa-f]{64}")) {
-            return false;
-        }
-        byte[] expected = generate(params, hashKey, hashIv).getBytes(StandardCharsets.US_ASCII);
-        byte[] actual = received.toUpperCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII);
-        return MessageDigest.isEqual(expected, actual);
-    }
-
-    /** .NET 风格 URLEncode:URLEncoder 后把 .NET 不编码的 {@code - _ . ! * ( )} 还原。 */
-    private static String dotNetUrlEncode(String s) {
-        // Java URLEncoder 对整串编码:& = / : 与空格(→+) 也会被编码(与 PHP/ECPay 算法一致,保留编码)
-        String enc = URLEncoder.encode(s, StandardCharsets.UTF_8);
-        // %2D/-、%5F/_、%2E/. Java 本就不编,列为防御;%21/!、%2A/*、%28/(、%29/) Java 会编,必须还原
-        return enc.replace("%2D", "-")
-                .replace("%5F", "_")
-                .replace("%2E", ".")
-                .replace("%21", "!")
-                .replace("%2A", "*")
-                .replace("%28", "(")
-                .replace("%29", ")");
-    }
-
-    private static String hashHexUpper(String s, String algorithm) {
-        try {
-            MessageDigest md = MessageDigest.getInstance(algorithm);
-            byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8));
-            StringBuilder hex = new StringBuilder(digest.length * 2);
-            for (byte b : digest) {
-                hex.append(String.format("%02X", b));
-            }
-            return hex.toString();
-        } catch (Exception e) {
-            throw new RuntimeException(algorithm + " unavailable", e);
-        }
-    }
-
-    /**
-     * 自测。
-     *
-     * <p>用例 1(ground truth):ECPay 物流官方 Checksum 文档向量 + MD5,编码 pipeline 经官方逐步演示,
-     * 期望 {@code 754C5D1365035DA34D2CD91CC256F18C}。此用例 100% 锁定「排序/包夹/URLEncode/替换/小写」全链路正确。
-     *
-     * <p>用例 2:OMG/ECPay AIO 支付向量 + SHA256(research D1 引用)。
-     */
-    public static void main(String[] args) {
-        // 用例(ground truth):ECPay 物流官方 Checksum 文档向量 + MD5。此向量 100% 锁定
-        // 「排序/包夹/.NET URLEncode/替换/小写」整条 pre-digest pipeline(含 = 与 & 的编码)。
-        Map<String, String> logistics = new java.util.LinkedHashMap<>();
-        logistics.put("MerchantID", "2000933");
-        logistics.put("MerchantTradeNo", "A20130312153023");
-        logistics.put("MerchantTradeDate", "2013/03/12 15:30:23");
-        logistics.put("LogisticsType", "CVS");
-        logistics.put("LogisticsSubType", "FAMIC2C");
-        logistics.put("GoodsAmount", "1000");
-        logistics.put("IsCollection", "N");
-        logistics.put("ServerReplyURL", "https://www.ecpay.com.tw/ServerReplyURL");
-        logistics.put("SenderName", "SteveJones");
-        logistics.put("ReceiverName", "MarkHarrison");
-        logistics.put("ReceiverStoreID", "001779");
-        String md5Expected = "754C5D1365035DA34D2CD91CC256F18C";
-        String md5Actual = generate(logistics, "XBERn1YOvpM9nfZc", "h1ONHk4P4yqbl5LK", "MD5");
-        boolean md5Pass = md5Expected.equalsIgnoreCase(md5Actual);
-        System.out.println("[MD5 ground-truth] " + (md5Pass ? "PASS" : "FAIL") + "  expected=" + md5Expected + " actual=" + md5Actual);
-        // OMG 生产用 SHA256(EncryptType=1),与上面 MD5 用例共用完全相同的 pre-digest pipeline,仅最后一步
-        // MessageDigest 不同。pipeline 既已由上方 ECPay 物流官方向量(MD5)锁定,SHA256 正确性随之确定。
-        // 早期版本曾硬编码一个 SHA256 期望值,但其参数与用例不一致、自测永远 FAIL,已移除;SHA256 端到端
-        // 以联调 stage 实测为准(凭证验证 queryTrade 会发真实 CheckMacValue,OMG 接受即证明)。
-        System.out.println("结论:编码 pipeline " + (md5Pass ? "正确" : "错误") + ";OMG 生产用 SHA256 仅末步摘要不同,联调时以官方凭证响应验签为准。");
-        if (!md5Pass) throw new AssertionError("CheckMacValue pipeline broken");
-    }
-}

+ 0 - 298
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java

@@ -1,298 +0,0 @@
-package com.ruoyi.app.utils.omg;
-
-import org.apache.http.NameValuePair;
-import org.apache.http.client.config.RequestConfig;
-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.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.stereotype.Component;
-
-import java.net.URI;
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * OMG(歐買尬/FunPoint) AIO 支付 HTTP 客户端与组参工具(httpclient4)。
- *
- * <p>独立于 newebpay 包,签名统一用 {@link OmgCheckMacValue}(SHA256),无 AES、无解密。
- *
- * <p>三类操作:
- * <ul>
- *   <li>{@link #createAioForm}:幕前下单。后端组参 + 算 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map,
- *       前端构建隐藏 form 自动 submit 到 OMG 收银台(本身不发 HTTP)。</li>
- *   <li>{@link #queryTrade}:订单查询 QueryTradeInfo/V5(凭证验证探测 / 回调漏收补单),后端 POST,响应 k=v。</li>
- *   <li>{@link #doAction}:信用卡退款/取消 CreditDetail/DoAction(US4),后端 POST,响应 k=v。</li>
- * </ul>
- *
- * @author ruoyi
- * @date 2026-07-29
- */
-@Component
-public class OmgPay {
-
-    private static final Logger log = LoggerFactory.getLogger(OmgPay.class);
-
-    /** 幕前下单端点(前端 Form Post 目标) */
-    public static final String URL_AIO_CHECKOUT = "/Cashier/AioCheckOut/V5";
-    /** 订单查询端点 */
-    public static final String URL_QUERY_TRADE_INFO = "/Cashier/QueryTradeInfo/V5";
-    /** 信用卡退款/取消端点 */
-    public static final String URL_DO_ACTION = "/CreditDetail/DoAction";
-
-    private static final int MAX_RESPONSE_BYTES = 64 * 1024;
-    private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
-            .setConnectTimeout(5000)
-            .setConnectionRequestTimeout(5000)
-            .setSocketTimeout(10000)
-            .build();
-
-    /**
-     * 幕前下单组参:对业务参数生成 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map。
-     * 前端用返回字段构建隐藏 form,action={@code gatewayUrl},其余为 input,submit 跳转 OMG 收银台。
-     *
-     * @param baseUrl OMG 根地址(测试 payment-stage.funpoint.com.tw / 正式 payment.funpoint.com.tw)
-     * @param cfg     门店 OMG 凭证
-     * @param params  业务参数(MerchantID/MerchantTradeNo/MerchantTradeDate/PaymentType=aio/TotalAmount/
-     *                ReturnURL/ChoosePayment=ALL/EncryptType=1/ItemName 等,不含 CheckMacValue)
-     * @return form 字段 Map(含 CheckMacValue 与 gatewayUrl;gatewayUrl 不参与签名)
-     */
-    public Map<String, String> createAioForm(String baseUrl, OmgPayConfig cfg, Map<String, String> params) {
-        validateConfig(cfg);
-        if (params == null || params.isEmpty()) {
-            throw new IllegalArgumentException("OMG checkout parameters are required");
-        }
-        Map<String, String> form = new LinkedHashMap<>(params);
-        // 仅对业务参数签名(form 此刻不含 CheckMacValue/gatewayUrl)
-        String checkMacValue = OmgCheckMacValue.generate(form, cfg.getHashKey(), cfg.getHashIv());
-        form.put("CheckMacValue", checkMacValue);
-        form.put("gatewayUrl", endpoint(baseUrl, URL_AIO_CHECKOUT));
-        log.info("[OMG] createAioForm >>> gatewayUrl={}, MerchantTradeNo={}, MerchantID={}",
-                form.get("gatewayUrl"), form.get("MerchantTradeNo"), form.get("MerchantID"));
-        return form;
-    }
-
-    /**
-     * 订单查询 QueryTradeInfo/V5(凭证验证探测 / 回调漏收补单)。响应为 k=v 文本,解析为 Map。
-     * TradeStatus:0 未付 / 1 已付 / 10200095 失败。
-     */
-    public Map<String, String> queryTrade(String baseUrl, OmgPayConfig cfg, String merchantTradeNo) throws Exception {
-        validateConfig(cfg);
-        requireText(merchantTradeNo, "MerchantTradeNo");
-        Map<String, String> params = new LinkedHashMap<>();
-        params.put("MerchantID", cfg.getMerchantId());
-        params.put("MerchantTradeNo", merchantTradeNo);
-        params.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
-        params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
-        String resp = postForm(endpoint(baseUrl, URL_QUERY_TRADE_INFO), params);
-        log.info("[OMG] queryTrade 查询返回 resp={}",resp);
-        Map<String, String> result;
-        try {
-            result = parseKvResponse(resp);
-        } catch (RuntimeException e) {
-            log.error("[OMG] queryTrade 解析响应失败 MerchantTradeNo={} resp={}", merchantTradeNo, resp, e);
-            throw e;
-        }
-        try {
-            verifyResponse(result, cfg, true);
-        } catch (RuntimeException e) {
-            String receivedCmv = result.get("CheckMacValue");
-            log.error("[OMG] queryTrade 验签失败 MerchantTradeNo={} TradeStatus={} RtnCode={} receivedCMV={} "
-                    + "nat_inc={} ci_inc={} nat_exc={} ci_exc={} hashKey={} hashIv={} resp={}",
-                    merchantTradeNo, result.get("TradeStatus"), result.get("RtnCode"), receivedCmv,
-                    safeGen(result, cfg, Comparator.naturalOrder(), false),
-                    safeGen(result, cfg, String.CASE_INSENSITIVE_ORDER, false),
-                    safeGen(result, cfg, Comparator.naturalOrder(), true),
-                    safeGen(result, cfg, String.CASE_INSENSITIVE_ORDER, true),
-                    mask(cfg.getHashKey()), mask(cfg.getHashIv()), resp, e);
-            throw e;
-        }
-        verifyCorrelation(result, "MerchantID", cfg.getMerchantId());
-        verifyCorrelation(result, "MerchantTradeNo", merchantTradeNo);
-        return result;
-    }
-
-    /**
-     * 信用卡退款/取消 CreditDetail/DoAction(US4)。Action:C 關帳 / R 退刷 / E 取消 / N 放棄。
-     * 仅正式端点可用(stage 不可用)。响应为 k=v 文本,RtnCode=1 成功。
-     */
-    public Map<String, String> doAction(String baseUrl, OmgPayConfig cfg, String merchantTradeNo,
-                                        String tradeNo, String action, int totalAmount) throws Exception {
-        validateConfig(cfg);
-        requireText(merchantTradeNo, "MerchantTradeNo");
-        requireText(tradeNo, "TradeNo");
-        if (!("C".equals(action) || "R".equals(action) || "E".equals(action) || "N".equals(action))) {
-            throw new IllegalArgumentException("Unsupported OMG action");
-        }
-        if (totalAmount <= 0) {
-            throw new IllegalArgumentException("TotalAmount must be positive");
-        }
-        Map<String, String> params = new LinkedHashMap<>();
-        params.put("MerchantID", cfg.getMerchantId());
-        params.put("MerchantTradeNo", merchantTradeNo);
-        params.put("TradeNo", tradeNo);
-        params.put("Action", action);
-        params.put("TotalAmount", String.valueOf(totalAmount));
-        params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
-        String resp = postForm(endpoint(baseUrl, URL_DO_ACTION), params);
-        Map<String, String> result = parseKvResponse(resp);
-        verifyResponse(result, cfg, false);
-        if (!result.containsKey("RtnCode")) {
-            throw new IllegalStateException("OMG response missing RtnCode");
-        }
-        return result;
-    }
-
-    /** 表单 POST(application/x-www-form-urlencoded),返回响应文本。 */
-    private String postForm(String url, Map<String, String> params) throws Exception {
-        List<NameValuePair> pairs = new ArrayList<>();
-        for (Map.Entry<String, String> e : params.entrySet()) {
-            pairs.add(new BasicNameValuePair(e.getKey(), e.getValue()));
-        }
-        HttpPost post = new HttpPost(url);
-        post.setEntity(new UrlEncodedFormEntity(pairs, StandardCharsets.UTF_8));
-        log.info("[OMG] postForm >>> url={}, MerchantTradeNo={}, MerchantID={}", url,
-                params.get("MerchantTradeNo"), params.get("MerchantID"));
-        try (CloseableHttpClient client = HttpClients.custom()
-                .setDefaultRequestConfig(REQUEST_CONFIG)
-                .disableRedirectHandling()
-                .build();
-             CloseableHttpResponse res = client.execute(post)) {
-            int status = res.getStatusLine().getStatusCode();
-            if (status < 200 || status >= 300) {
-                throw new IllegalStateException("OMG returned HTTP " + status);
-            }
-            if (res.getEntity() == null) {
-                throw new IllegalStateException("OMG returned an empty response");
-            }
-            long declaredLength = res.getEntity().getContentLength();
-            if (declaredLength > MAX_RESPONSE_BYTES) {
-                throw new IllegalStateException("OMG response is too large");
-            }
-            byte[] body = EntityUtils.toByteArray(res.getEntity());
-            if (body.length == 0 || body.length > MAX_RESPONSE_BYTES) {
-                throw new IllegalStateException("OMG returned an invalid response size");
-            }
-            String resp = new String(body, StandardCharsets.UTF_8).trim();
-            if (resp.isEmpty()) {
-                throw new IllegalStateException("OMG returned a blank response");
-            }
-            log.info("[OMG] postForm <<< status={}, bytes={}", status, body.length);
-            return resp;
-        }
-    }
-
-    /** 解析 OMG k=v 文本响应(QueryTradeInfo/DoAction 返回 text/html,以 & 分隔)。 */
-    Map<String, String> parseKvResponse(String resp) {
-        Map<String, String> map = new LinkedHashMap<>();
-        if (resp == null || resp.trim().isEmpty()) {
-            throw new IllegalArgumentException("OMG response is blank");
-        }
-        for (String pair : resp.split("&", -1)) {
-            if (pair.isEmpty()) {
-                // 尾随 & 或连续 && 产生的空段是 OMG 真实响应常态,跳过;
-                // 空段不含 key=value,不进入 map,不影响 CheckMacValue 验签。
-                continue;
-            }
-            int idx = pair.indexOf('=');
-            if (idx <= 0) {
-                throw new IllegalArgumentException("Malformed OMG response field");
-            }
-            String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
-            String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
-            if (key.isEmpty()) {
-                throw new IllegalArgumentException("Empty OMG response field");
-            }
-            // 重复键 last-wins(不抛):OMG 大响应(NeedExtraPaidInfo=Y)若含重复键,
-            // 不让 queryTrade 在此直接失败;log.warn 记录便于排查,验签用同一 map 重算把关。
-            String prev = map.put(key, value);
-            if (prev != null) {
-                log.warn("[OMG] 响应含重复键(取 last-wins): key={}, prev={}, cur={}", key, prev, value);
-            }
-            if (map.size() > 100) {
-                throw new IllegalArgumentException("Too many OMG response fields");
-            }
-        }
-        return map;
-    }
-
-    private void verifyResponse(Map<String, String> result, OmgPayConfig cfg, boolean signatureRequired) {
-        String checkMacValue = result.get("CheckMacValue");
-        if (checkMacValue == null || checkMacValue.isEmpty()) {
-            if (signatureRequired) {
-                throw new IllegalStateException("OMG response missing CheckMacValue");
-            }
-            return;
-        }
-        if (!OmgCheckMacValue.verify(result, cfg.getHashKey(), cfg.getHashIv())) {
-            throw new IllegalStateException("OMG response CheckMacValue mismatch");
-        }
-    }
-
-    private void verifyCorrelation(Map<String, String> result, String key, String expected) {
-        String actual = result.get(key);
-        if (actual != null && !actual.isEmpty() && !expected.equals(actual)) {
-            throw new IllegalStateException("OMG response " + key + " mismatch");
-        }
-    }
-
-    private String endpoint(String baseUrl, String path) {
-        requireText(baseUrl, "OMG base URL");
-        URI uri = URI.create(baseUrl.trim());
-        if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null
-                || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
-                || (uri.getPath() != null && !uri.getPath().isEmpty() && !"/".equals(uri.getPath()))) {
-            throw new IllegalArgumentException("OMG base URL must be a plain HTTPS origin");
-        }
-        String normalized = baseUrl.trim();
-        while (normalized.endsWith("/")) {
-            normalized = normalized.substring(0, normalized.length() - 1);
-        }
-        return normalized + path;
-    }
-
-    private void validateConfig(OmgPayConfig cfg) {
-        if (cfg == null) {
-            throw new IllegalArgumentException("OMG credentials are required");
-        }
-        requireText(cfg.getMerchantId(), "MerchantID");
-        requireText(cfg.getHashKey(), "HashKey");
-        requireText(cfg.getHashIv(), "HashIV");
-    }
-
-    private void requireText(String value, String name) {
-        if (value == null || value.trim().isEmpty()) {
-            throw new IllegalArgumentException(name + " is required");
-        }
-    }
-
-    /** 脱敏凭证用于日志:前2后2+长度,够排查贴错又不泄露完整值。 */
-    private static String mask(String s) {
-        if (s == null) {
-            return "null";
-        }
-        if (s.length() <= 4) {
-            return "***(len=" + s.length() + ")";
-        }
-        return s.substring(0, 2) + "***" + s.substring(s.length() - 2) + "(len=" + s.length() + ")";
-    }
-
-    /** 诊断用:用指定排序/空值策略重算 CheckMacValue,失败返回 err 标记。 */
-    private String safeGen(Map<String, String> result, OmgPayConfig cfg, Comparator<String> order, boolean excludeEmpty) {
-        try {
-            return OmgCheckMacValue.generate(result, cfg.getHashKey(), cfg.getHashIv(), order, excludeEmpty);
-        } catch (Exception ce) {
-            return "err:" + ce.getClass().getSimpleName();
-        }
-    }
-}

+ 0 - 37
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPayConfig.java

@@ -1,37 +0,0 @@
-package com.ruoyi.app.utils.omg;
-
-import lombok.Data;
-
-/**
- * OMG(歐買尬/FunPoint) 商店配置(运行时传入,支持多门店)。
- *
- * <p>每个门店在 OMG 平台申请商店后会拿到一组专属金钥,由业务层从 pos_store_omg 读取后构造本对象。
- * 申请入口:测试环境 https://payment-stage.funpoint.com.tw/ | 正式环境 https://payment.funpoint.com.tw/
- *
- * <p>OMG 签名用 CheckMacValue(SHA256, EncryptType=1),与蓝新 NewebPay(AES+SHA256) 不同,
- * 故本配置与签名工具均独立于 newebpay 包。
- *
- * @author ruoyi
- * @date 2026-07-29
- */
-@Data
-public class OmgPayConfig {
-
-    /** 商店代号 MerchantID(OMG 分配)。 */
-    private String merchantId;
-
-    /** 商店 HashKey(OMG 分配)。 */
-    private String hashKey;
-
-    /** 商店 HashIV(OMG 分配)。 */
-    private String hashIv;
-
-    public OmgPayConfig() {
-    }
-
-    public OmgPayConfig(String merchantId, String hashKey, String hashIv) {
-        this.merchantId = merchantId;
-        this.hashKey = hashKey;
-        this.hashIv = hashIv;
-    }
-}

+ 0 - 30
ruoyi-admin/src/main/resources/application.yml

@@ -37,11 +37,6 @@ newebpay:
   # MPG 串接程式版本
   # MPG 串接程式版本
   mpg-version: "2.3"
   mpg-version: "2.3"
 
 
-# OMG(歐買尬/FunPoint) AIO 线上支付配置(016-omg-payment)
-omg:
-  # 幕前支付根地址(测试 payment-stage.funpoint.com.tw;正式 payment.funpoint.com.tw)
-  base-url: https://payment-stage.funpoint.com.tw
-
 omgpay:
 omgpay:
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/result
   order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/result
@@ -232,30 +227,5 @@ line-pay:
     round-budget-seconds: 45
     round-budget-seconds: 45
     auth-deadline-minutes: 30
     auth-deadline-minutes: 30
     unknown-deadline-hours: 24
     unknown-deadline-hours: 24
-# 正式环境参数 START ZaloPay支付配置
-#zalo:
-#  pay:
-#    configs:
-#      - app_id: "4514"
-#        key1: "ro6DlS5YYYMpptmW6mrq50qM1bChUGSM"
-#        key2: "6UIKRr2rtdQeOnpnihaNOx3qGuGJt50w"
-#        pay_type: "3"
-#        description: "钱包支付"
-#      - app_id: "4515"
-#        key1: "WgxqmfbwBfRpa1O1qoMvE3NMryn16oBY"
-#        key2: "rY1hgRRjbuEJY603TdiVDGwoVRcJUfw5"
-#        pay_type: "4"
-#        description: "二维码支付"
-#      - app_id: "4516"
-#        key1: "Bufv15SQ4OyoqL5cMEHPPRGul6HsBoIm"
-#        key2: "3YVsqWXUzee3gbsp9fwXSf8MAb7fnuC3"
-#        pay_type: "5"
-#        description: "银行卡支付"
-#    api:
-#      create_order_url: "https://openapi.zalopay.vn/v2/create"
-#      query_order_url: "https://openapi.zalopay.vn/v2/query"
-#      refund_url: "https://openapi.zalopay.vn/v2/refund"
-#      query_refund_url: "https://openapi.zalopay.vn/v2/query_refund"
-# 正式环境参数 END
 
 
 
 

+ 4 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java

@@ -16,6 +16,10 @@ class OmgLegacyRetirementTest {
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.pay.OmgPayController"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.pay.OmgPayController"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.task.OmgReconcileTask"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.task.OmgReconcileTask"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.system.domain.PosOrderOmgPayment"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.system.domain.PosOrderOmgPayment"));
+        assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.system.domain.PosOrderOmgRefund"));
+        assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.utils.omg.OmgPay"));
+        assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.utils.omg.OmgPayConfig"));
+        assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.utils.omg.OmgCheckMacValue"));
         assertNotNull(OmgPaymentController.class.getAnnotation(RestController.class));
         assertNotNull(OmgPaymentController.class.getAnnotation(RestController.class));
         Set<String> postPaths = Arrays.stream(OmgPaymentController.class.getDeclaredMethods())
         Set<String> postPaths = Arrays.stream(OmgPaymentController.class.getDeclaredMethods())
                 .filter(method -> method.isAnnotationPresent(PostMapping.class))
                 .filter(method -> method.isAnnotationPresent(PostMapping.class))

+ 83 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgStoreCredentialVerifierTest.java

@@ -0,0 +1,83 @@
+package com.ruoyi.app.omgpay;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OmgStoreCredentialVerifierTest {
+    private static final String HASH_KEY = "5294y06JbISpM5x9";
+    private static final String HASH_IV = "v77hoKGq4kWxNNIS";
+
+    private OmgPaymentQueryGateway gateway;
+    private OmgCheckMacSigner signer;
+    private OmgStoreCredentialVerifier verifier;
+
+    @BeforeEach
+    void setUp() {
+        gateway = mock(OmgPaymentQueryGateway.class);
+        signer = new OmgCheckMacSigner();
+        verifier = new OmgStoreCredentialVerifier(gateway, new OmgQueryResponseParser(), signer,
+                Clock.fixed(Instant.ofEpochSecond(1_786_598_400L), ZoneOffset.UTC));
+    }
+
+    @Test
+    void signsAStageProbeAndVerifiesEveryReturnedField() {
+        when(gateway.query(anyMap())).thenAnswer(invocation -> signedResponse(invocation.getArgument(0)));
+
+        Map<String, String> response = verifier.verify("1000031", HASH_KEY, HASH_IV);
+
+        assertEquals("0", response.get("TradeStatus"));
+        @SuppressWarnings("unchecked")
+        var request = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(gateway).query(request.capture());
+        assertEquals(Set.of("MerchantID", "MerchantTradeNo", "TimeStamp", "CheckMacValue"),
+                request.getValue().keySet());
+        assertEquals("1000031", request.getValue().get("MerchantID"));
+        assertEquals("OMGVERIFY1786598400", request.getValue().get("MerchantTradeNo"));
+        assertEquals("1786598400", request.getValue().get("TimeStamp"));
+        LinkedHashMap<String, String> signingFields = new LinkedHashMap<>(request.getValue());
+        String actual = signingFields.remove("CheckMacValue");
+        assertEquals(signer.sign(signingFields, HASH_KEY, HASH_IV), actual);
+    }
+
+    @Test
+    void rejectsAnyTamperedExtraResponseField() {
+        when(gateway.query(anyMap())).thenAnswer(invocation ->
+                signedResponse(invocation.getArgument(0)).replace("FutureEmptyField=", "FutureEmptyField=tampered"));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> verifier.verify("1000031", HASH_KEY, HASH_IV));
+    }
+
+    private String signedResponse(Map<String, String> request) {
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", request.get("MerchantID"));
+        fields.put("MerchantTradeNo", request.get("MerchantTradeNo"));
+        fields.put("TradeStatus", "0");
+        fields.put("RtnMsg", "Not found");
+        fields.put("FutureEmptyField", "");
+        fields.put("CheckMacValue", signer.sign(fields, HASH_KEY, HASH_IV));
+        return fields.entrySet().stream()
+                .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
+                .collect(java.util.stream.Collectors.joining("&"));
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+}

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java

@@ -91,7 +91,7 @@ class LinePayCancellationRaceTest {
         when(payments.markPaid(eq(70L), eq(2L), eq("FAILED"), eq("TX-1"),
         when(payments.markPaid(eq(70L), eq(2L), eq("FAILED"), eq("TX-1"),
                 any(), any())).thenReturn(1);
                 any(), any())).thenReturn(1);
         PosOrder order = order(1L);
         PosOrder order = order(1L);
-        order.setPayType("7");
+        order.setPayType("2");
         order.setPayStatus(1L);
         order.setPayStatus(1L);
         when(orders.getOne(any(Wrapper.class))).thenReturn(order);
         when(orders.getOne(any(Wrapper.class))).thenReturn(order);
         when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(0);
         when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(0);

+ 0 - 105
ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgCheckMacValueTest.java

@@ -1,105 +0,0 @@
-package com.ruoyi.app.utils.omg;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.Comparator;
-import java.util.LinkedHashMap;
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-class OmgCheckMacValueTest {
-
-    @Test
-    void generatesOfficialEcpayLogisticsVector() {
-        Map<String, String> params = new LinkedHashMap<>();
-        params.put("MerchantID", "2000933");
-        params.put("MerchantTradeNo", "A20130312153023");
-        params.put("MerchantTradeDate", "2013/03/12 15:30:23");
-        params.put("LogisticsType", "CVS");
-        params.put("LogisticsSubType", "FAMIC2C");
-        params.put("GoodsAmount", "1000");
-        params.put("IsCollection", "N");
-        params.put("ServerReplyURL", "https://www.ecpay.com.tw/ServerReplyURL");
-        params.put("SenderName", "SteveJones");
-        params.put("ReceiverName", "MarkHarrison");
-        params.put("ReceiverStoreID", "001779");
-
-        assertEquals("754C5D1365035DA34D2CD91CC256F18C",
-                OmgCheckMacValue.generate(params, "XBERn1YOvpM9nfZc", "h1ONHk4P4yqbl5LK", "MD5"));
-    }
-
-    @Test
-    void verifiesSha256WithoutDependingOnHexCase() {
-        Map<String, String> params = new LinkedHashMap<>();
-        params.put("MerchantID", "2000132");
-        params.put("MerchantTradeNo", "OMG12345678901234567");
-        String signature = OmgCheckMacValue.generate(params, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS");
-        params.put("CheckMacValue", signature.toLowerCase());
-
-        assertTrue(OmgCheckMacValue.verify(params, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS"));
-    }
-
-    @Test
-    void rejectsMalformedOrIncorrectSignature() {
-        Map<String, String> params = new LinkedHashMap<>();
-        params.put("MerchantID", "2000132");
-        params.put("CheckMacValue", "not-a-sha256");
-        assertFalse(OmgCheckMacValue.verify(params, "key", "iv"));
-
-        params.put("CheckMacValue", "0".repeat(64));
-        assertFalse(OmgCheckMacValue.verify(params, "key", "iv"));
-    }
-
-    /**
-     * 回归 bugB(2026-08-12):OMG QueryTradeInfo 响应字段大小写混合,服务端 CheckMacValue 按
-     * 大小写不敏感排序计算。旧实现用 Comparator.naturalOrder()(ASCII 大小写敏感)排序,混合字段下
-     * 与 OMG 分歧 → 验签失败(生产订单 991786433092835 实测 receivedCMV==ci_inc 坐实)。
-     *
-     * <p>本用例构造同形响应(首字母大写的 AlipayID/CustomField1/MerchantID 与首字母小写的
-     * amount/auth_code/card4no/eci/gwsr/process_date/red_dan 并存),先用「大小写不敏感」生成 OMG 侧
-     * 签名,再断言 verify() 通过。修复前 verify 走 naturalOrder 会算出不同签名而失败。
-     */
-    @Test
-    void verifiesQueryTradeInfoResponseWithCaseInsensitiveKeyOrder() {
-        Map<String, String> resp = new LinkedHashMap<>();
-        resp.put("MerchantID", "1000031");
-        resp.put("MerchantTradeNo", "OMG10C9AA21B1814EE29");
-        resp.put("TradeAmt", "97");
-        resp.put("TradeDate", "2026/08/11 17:06:42");
-        resp.put("TradeNo", "2608111706428239");
-        resp.put("TradeStatus", "10200095");
-        resp.put("ItemName", "order 991786433092835");
-        resp.put("HandlingCharge", "0");
-        resp.put("PaymentTypeChargeFee", "0");
-        // §2 额外回传(空值),首字母大小写混合 —— 触发排序分歧的关键
-        resp.put("AlipayID", "");
-        resp.put("CustomField1", "");
-        resp.put("amount", "");
-        resp.put("auth_code", "");
-        resp.put("card4no", "");
-        resp.put("eci", "");
-        resp.put("gwsr", "");
-        resp.put("process_date", "");
-        resp.put("red_dan", "");
-
-        String key = "5294y06JbISpM5x9";
-        String iv = "v77hoKGq4kWxNNIS";
-
-        // OMG 侧签名:大小写不敏感排序,保留空值字段
-        String omgCmv = OmgCheckMacValue.generate(resp, key, iv,
-                String.CASE_INSENSITIVE_ORDER, false);
-        // 前置断言:本字段集必须使两种排序产生不同签名,否则用例无法锁定该 bug
-        String sensitiveCmv = OmgCheckMacValue.generate(resp, key, iv,
-                Comparator.naturalOrder(), false);
-        assertNotEquals(sensitiveCmv, omgCmv,
-                "测试字段集必须使大小写敏感/不敏感排序产生不同签名,否则用例无效");
-
-        resp.put("CheckMacValue", omgCmv);
-        assertTrue(OmgCheckMacValue.verify(resp, key, iv),
-                "verify 必须按大小写不敏感排序以匹配 OMG 服务端算法");
-    }
-}

+ 0 - 61
ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgPayTest.java

@@ -1,61 +0,0 @@
-package com.ruoyi.app.utils.omg;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-class OmgPayTest {
-
-    private final OmgPay omgPay = new OmgPay();
-
-    @Test
-    void parsesAndUrlDecodesKvResponse() {
-        Map<String, String> result = omgPay.parseKvResponse(
-                "MerchantTradeNo=OMG123&PaymentDate=2026%2F08%2F06+12%3A30%3A00&RtnMsg=Paid%26OK");
-
-        assertEquals("OMG123", result.get("MerchantTradeNo"));
-        assertEquals("2026/08/06 12:30:00", result.get("PaymentDate"));
-        assertEquals("Paid&OK", result.get("RtnMsg"));
-    }
-
-    @Test
-    void rejectsMalformedFieldsButAllowsDuplicateKeys() {
-        // 重复键 last-wins 不抛(T051 放宽:OMG 大响应若含重复键,不让 queryTrade 直接失败;验签用同一 map 把关)
-        Map<String, String> dup = omgPay.parseKvResponse("RtnCode=1&RtnCode=0");
-        assertEquals("0", dup.get("RtnCode")); // last-wins
-        // 无 = / 空 key 仍抛
-        assertThrows(IllegalArgumentException.class,
-                () -> omgPay.parseKvResponse("RtnCode=1&broken"));
-        assertThrows(IllegalArgumentException.class,
-                () -> omgPay.parseKvResponse("=value"));
-        // 尾随 & 的空段不再抛(parseKvResponse 已放宽),见 toleratesTrailingAmpersandAndEmptyValue
-    }
-
-    @Test
-    void rejectsMoreThanOneHundredFields() {
-        StringBuilder response = new StringBuilder();
-        for (int i = 0; i <= 100; i++) {
-            if (i > 0) {
-                response.append('&');
-            }
-            response.append('k').append(i).append("=v");
-        }
-
-        assertThrows(IllegalArgumentException.class, () -> omgPay.parseKvResponse(response.toString()));
-    }
-
-    @Test
-    void toleratesTrailingAmpersandAndEmptyValue() {
-        // OMG 真实响应常带尾随 & 与空值字段(如 PaymentDate=),parseKvResponse 放宽后不应抛
-        Map<String, String> result = omgPay.parseKvResponse(
-                "RtnCode=1&PaymentDate=&RtnMsg=OK&");
-
-        assertEquals(3, result.size());
-        assertEquals("1", result.get("RtnCode"));
-        assertEquals("", result.get("PaymentDate"));
-        assertEquals("OK", result.get("RtnMsg"));
-    }
-}

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -272,7 +272,7 @@ public class PosOrder {
     private Date sdTime;
     private Date sdTime;
 
 
     /**
     /**
-     * 支付方式:1 到付,2 VNPAY,3 LINE Pay,4 银行卡,5 余额,7 OMG
+     * 支付方式:1 到付,2 OMG,3 LINE Pay,4 银行卡,5 余额
      */
      */
     private String payType;
     private String payType;
 
 

+ 1 - 1
specs/016-omg-payment/callback-reconcile.md

@@ -62,7 +62,7 @@
 **核心逻辑(实现时照此)**
 **核心逻辑(实现时照此)**
 ```
 ```
 1. 校验登录 + 订单归属(token → userId == order.userId)
 1. 校验登录 + 订单归属(token → userId == order.userId)
-2. order.payType 必须为 "7";若 order.payStatus 已为 1 → 直接返回成功(不重复处理)
+2. order.payType 必须为 "2";若 order.payStatus 已为 1 → 直接返回成功(不重复处理)
 3. payment = paymentService.getLatestByDdId(ddId)  // 最新一条 OMG 流水
 3. payment = paymentService.getLatestByDdId(ddId)  // 最新一条 OMG 流水
 4. 门店凭证 cred = storeOmgService.getEnabledCredential(order.mdId) → OmgPayConfig
 4. 门店凭证 cred = storeOmgService.getEnabledCredential(order.mdId) → OmgPayConfig
 5. resp = omgPay.queryTrade(baseUrl, cfg, payment.getMerchantTradeNo)
 5. resp = omgPay.queryTrade(baseUrl, cfg, payment.getMerchantTradeNo)

+ 1 - 1
specs/016-omg-payment/data-model.md

@@ -92,7 +92,7 @@
 |------|------|----------|
 |------|------|----------|
 | dd_id | 订单号 | 与支付流水关联;MerchantTradeNo 独立生成且不可复用 |
 | dd_id | 订单号 | 与支付流水关联;MerchantTradeNo 独立生成且不可复用 |
 | amount | 订单合计金额(Integer 元) | OMG 支付金额(含运费;与发票 amount−freight 不同) |
 | amount | 订单合计金额(Integer 元) | OMG 支付金额(含运费;与发票 amount−freight 不同) |
-| pay_type | 支付方式字符串 | 新增取值 `"7"` = OMG 在线支付(发起时写入) |
+| pay_type | 支付方式字符串 | 取值 `"2"` = OMG 在线支付(发起时写入) |
 | pay_status | 0 未支付/1 已支付/2 已退款 | 回调成功置 1(沿用);退款置 2 |
 | pay_status | 0 未支付/1 已支付/2 已退款 | 回调成功置 1(沿用);退款置 2 |
 | pay_url | 支付地址 | 存 OMG gatewayUrl(兜底/记录) |
 | pay_url | 支付地址 | 存 OMG gatewayUrl(兜底/记录) |
 | md_id | 门店ID | 决定用哪个门店的 OMG 凭证发起交易 |
 | md_id | 门店ID | 决定用哪个门店的 OMG 凭证发起交易 |

+ 370 - 0
specs/019-line-pay/app-integration.md

@@ -0,0 +1,370 @@
+# LINE Pay 前端接入文档(客户 App)
+
+> 适用对象:客户 App(uni-app)前端开发人员
+> 对应后端:foodie_server `019-line-pay`,`LinePayController`(`/pay/line/**`)
+> 后端契约权威:本目录 `contracts/api.md`、`spec.md`
+> 最后更新:2026-08-13
+
+本文档只覆盖**客户 App 需要对接的部分**:发起支付、跳转 LINE 收银台、接收 App Scheme 回跳、查询最终结果。门店凭证配置(平台管理端)详见 `contracts/api.md` 第 3 节,不在本文档范围。
+
+---
+
+## 1. 概述
+
+LINE Pay 直连是项目餐饮订单的线上支付渠道之一,订单字段 `pos_order.pay_type = "3"` 即表示该笔订单使用 LINE Pay。
+
+**本期约束(前端必须知道):**
+
+- 仅支持**单门店**餐饮订单;多门店父单无法发起 LINE Pay(后端会拒绝)。
+- 币种固定 **TWD**,金额为整数,全部由**服务端订单决定**,前端不传金额。
+- 仅支持**全额退款**,且退款由后端在订单取消时自动处理,**前端没有独立的退款接口**。
+- LINE Pay 不提供同步成功回调。**支付结果永远以 `/pay/line/query` 的返回为准**,绝不能信任 Scheme 参数或 LINE 收银台页面判断是否付款成功。
+
+---
+
+## 2. 整体流程
+
+```mermaid
+sequenceDiagram
+    participant App as 客户 App
+    participant Server as foodie_server
+    participant Line as LINE 收银台
+
+    App->>Server: ① 下单(订单 payType=3, LINE Pay)
+    App->>Server: ② POST /pay/line/create {ddId}
+    Server-->>App: 返回 paymentUrl + 状态 WAITING_AUTH
+    App->>Line: ③ 在 WebView / 外部浏览器打开 paymentUrl
+    Note over Line: 用户完成 LINE 登录授权
+    Line->>Server: ④ LINE 回跳 /pay/line/confirm(后端处理)
+    Server-->>Line: 返回中间页 HTML(快速返回,不同步 Confirm)
+    Note over Server: 后端异步 Confirm + 请款
+    Server->>App: ⑤ 中间页唤起 Scheme<br/>com.twanmsdyh.app://payment/result?orderId=ddId
+    App->>Server: ⑥ POST /pay/line/query {ddId}(带 token)
+    Server-->>App: 返回最终状态(PAID / 退款等)
+    Note over App: 若仍处理中,轮询 query 直到终态
+```
+
+**一句话流程:** 下单选 LINE Pay → 调 `create` 拿收银台链接 → 打开让用户授权 → App 被 Scheme 唤回 → 调 `query` 拿最终结果。
+
+---
+
+## 3. 前置条件
+
+1. **门店已开通 LINE Pay 并启用**:订单所属门店在平台管理端配置了有效 Channel 凭证且处于启用状态。否则 `create` 会返回业务错误。
+2. **订单 `payType = "3"`**:下单时必须把支付方式指定为 LINE Pay。后端 `create` 会校验当前 `payType`,**不允许 create 时临时切换渠道**。
+3. **订单可支付**:未支付、未取消、未完成。堂食订单(`state=1`)也可支付。
+4. **用户已登录**:`create` / `query` 都需要请求头携带登录 `token`。
+5. **App 已注册自定义 Scheme**:见第 5 节。
+
+---
+
+## 4. 接口说明
+
+所有接口走项目统一 `AjaxResult` 外壳:`{ code, msg, data }`。`code === 200` 为成功,其余为业务错误,`msg` 为国际化错误文案(直接展示即可)。
+
+> **重要:`transactionId` 是 19 位长数字,必须按字符串处理。** 用 JS `Number` / 算术运算会导致精度丢失。JSON 解析、存储、比较全程保持字符串。
+
+### 4.1 创建支付 `POST /pay/line/create`
+
+向 LINE 发起支付请求,返回 LINE 收银台地址。
+
+**请求**
+
+- Header:`token: <登录 token>`(必填)
+- Body(JSON):
+
+```json
+{
+  "ddId": "202608120001"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `ddId` | string | 是 | 业务订单号 `pos_order.dd_id` |
+
+**成功响应 `data`**
+
+```json
+{
+  "ddId": "202608120001",
+  "paymentId": 42,
+  "lineOrderId": "LP2026081200010001",
+  "transactionId": "2026081200000000001",
+  "paymentUrl": "https://sandbox-web-pay.line.me/...",
+  "status": "WAITING_AUTH",
+  "reusedAttempt": false
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `ddId` | string | 回显订单号 |
+| `paymentId` | number | 后端支付流水主键,排查问题时提供 |
+| `lineOrderId` | string | 后端生成的 LINE 订单号(**不是 ddId**),前端一般不用 |
+| `transactionId` | string \| null | LINE 19 位交易号,**字符串**,可能为空(尚未生成时) |
+| `paymentUrl` | string | **LINE 收银台地址,前端需在浏览器/WebView 打开它** |
+| `status` | string | 支付状态(见第 6 节),正常为 `WAITING_AUTH` |
+| `reusedAttempt` | boolean | `true` 表示本次复用了已有支付尝试(见下方说明) |
+
+**关于 `reusedAttempt`(重复点击处理):**
+
+后端保证同一订单**任意时刻最多一条活跃 LINE 支付尝试**。前端重复点击「去支付」时:
+
+- 若上一笔仍在等待授权 / 处理中,后端**复用原尝试**,返回**同一个 `paymentUrl`**,`reusedAttempt = true` —— 前端直接用返回的 `paymentUrl` 跳转即可,**不要当成错误**。
+- 若上一笔已被 LINE 明确判为取消 / 过期 / 失败,后端才会新建尝试,`reusedAttempt = false`。
+- 若已支付,返回的 `status` 为 `PAID`。
+
+**业务错误(`code !== 200`)**
+
+`msg` 已国际化,典型场景:订单不存在 / 非本人订单 / `payType` 不是 3 / 多门店父单 / 订单不可支付 / 门店未开通或未启用 / 已有处理中尝试需稍后重试。前端直接展示 `msg`,无需自己映射。
+
+---
+
+### 4.2 查询支付 `POST /pay/line/query`
+
+查询订单的支付/退款最终状态。**该接口只读本地数据库,不会同步去调 LINE,响应很快。**
+
+**请求**
+
+- Header:`token: <登录 token>`(必填)
+- Body(JSON):
+
+```json
+{
+  "ddId": "202608120001"
+}
+```
+
+**成功响应 `data`**
+
+```json
+{
+  "ddId": "202608120001",
+  "paymentId": 42,
+  "orderPayStatus": 1,
+  "paymentStatus": "PAID",
+  "refundStatus": null,
+  "transactionId": "2026081200000000001",
+  "updatedAt": "2026-08-12T12:34:56+08:00"
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `ddId` | string | 回显订单号 |
+| `paymentId` | number | 当前选中的支付流水主键 |
+| `orderPayStatus` | number | **订单支付状态**,见下表 |
+| `paymentStatus` | string | LINE 支付流水状态,见第 6 节 |
+| `refundStatus` | string \| null | 退款状态,未退款为 `null`,见第 6 节 |
+| `transactionId` | string \| null | LINE 交易号,**字符串** |
+| `updatedAt` | string | 最近更新时间(ISO-8601) |
+
+**`orderPayStatus` 枚举(前端主要依据这个判定结果)**
+
+| 值 | 含义 | 前端处理 |
+|---|---|---|
+| `0` | 未支付 | 支付尚未完成(看 `paymentStatus` 判断是否还在处理中) |
+| `1` | 已支付 | 支付成功,展示成功页 |
+| `2` | 已退款 | 已全额退款(取消订单后触发) |
+
+---
+
+## 5. App Scheme 回跳
+
+LINE 收银台授权完成后,LINE 会回跳后端 `/pay/line/confirm`,后端返回一个**中间页**。该中间页会尝试唤起 App,并显示「支付结果确认中,请回 App 查看」的提示和「打开 App」按钮。
+
+**前端必须注册并处理以下固定 Scheme:**
+
+```text
+com.twanmsdyh.app://payment/result?orderId=<URL编码后的 ddId>
+```
+
+**处理规则(强制):**
+
+1. App 被 Scheme 唤起后,**读取本地登录 token**,带上 `ddId = orderId 参数` 调用 `POST /pay/line/query`。
+2. **绝不能把 Scheme 到达当作支付成功。** Scheme 参数可被伪造,`orderId` 只是用于回查的订单号,不含任何支付结果。
+3. `query` 返回 `orderPayStatus === 1` 才算真正成功。
+4. 若 `query` 返回仍在处理中(见第 7 节轮询),进入轮询,直到拿到终态或超时提示。
+
+> **Scheme 注册 + 自动唤起 + 兜底按钮,需在 iOS / Android 真机验收**(Sandbox 无法模拟 LINE App 内置浏览器和 App 自动唤起行为)。在真机接入前,中间页本身已能安全显示提示并保留「打开 App」按钮。
+
+---
+
+## 6. 状态枚举
+
+### 6.1 `paymentStatus`(LINE 支付流水状态)
+
+| 状态 | 含义 | 是否终态 |
+|---|---|---|
+| `WAITING_AUTH` | 等待用户在 LINE 授权 | 否 |
+| `READY_CONFIRM` | 已授权,待后端确认 | 否 |
+| `CONFIRMING` | 后端确认中 | 否 |
+| `PAID` | **已支付** | 是 |
+| `CANCELLED_OR_EXPIRED` | 用户取消或授权过期 | 是 |
+| `FAILED` | 支付失败 | 是 |
+| `MANUAL_REVIEW` | 进入人工核对(未知结果超时) | 是(需联系平台处理) |
+| `REQUESTING` / `REQUEST_UNKNOWN` | 请求中 / 请求结果未知 | 否(后端会恢复) |
+| `CONFIRM_UNKNOWN` | 确认结果未知 | 否(后端会恢复) |
+
+> 前端通常**只需重点识别 `PAID` 和非终态**。复杂的中间态由后端定时任务自动恢复,前端遇到非终态按第 7 节轮询即可。
+
+### 6.2 `refundStatus`(退款状态)
+
+| 状态 | 含义 |
+|---|---|
+| `null` | 无退款 |
+| `CREATED` / `PROCESSING` | 退款处理中 |
+| `REFUNDED` | **已全额退款** |
+| `FAILED` | 退款失败 |
+| `MANUAL_REVIEW` | 退款进入人工核对 |
+
+---
+
+## 7. 接入步骤清单
+
+按顺序完成即可:
+
+1. **下单接口**:确保下单时能把订单 `payType` 设为 `"3"`(LINE Pay)。
+2. **支付按钮**:用户点击「LINE Pay 支付」时,调用 `POST /pay/line/create`,拿到 `paymentUrl`。
+3. **打开收银台**:用 WebView(或外部浏览器)打开 `paymentUrl`。
+   - 若返回 `reusedAttempt === true`,正常使用同一个 `paymentUrl`,无需提示。
+   - 若返回 `status === "PAID"`,直接进成功流程,不必再跳转。
+4. **注册 Scheme**:在 App 配置中注册 `com.twanmsdyh.app://payment/result`,回调里取出 `orderId`。
+5. **回跳查询**:Scheme 回调触发后,带 token 调 `POST /pay/line/query`。
+6. **结果判定**:
+   - `orderPayStatus === 1` → 支付成功。
+   - `orderPayStatus === 2` → 已退款(取消订单场景)。
+   - `paymentStatus` 为 `PAID` 但订单处理中 → 走轮询。
+7. **轮询**(见下):若结果尚未终态,按固定间隔重试 `query`。
+8. **真机验收**:上线前在 iOS / Android 真机完成端到端支付 + Scheme 唤回验证。
+
+### 轮询策略建议
+
+`/pay/line/confirm` 是**异步确认 + 请款**的,最长可能需要数十秒。Scheme 唤回后第一次 `query` 可能仍是 `WAITING_AUTH` / `CONFIRMING`,需轮询:
+
+```text
+首次 query 后,若未终态:
+  - 每隔 3 秒查一次
+  - 最多查 10 次(约 30 秒)
+  - 仍未终态 → 提示「支付结果确认中,请稍后在订单列表查看」,不要报错
+```
+
+> 即使 App 被杀或 Scheme 丢失也没关系:后端有独立定时任务会主动向 LINE 确认/查询并落账,用户下次打开订单时调 `query` 仍能拿到最终结果。
+
+---
+
+## 8. 代码示例(参考)
+
+```javascript
+// 1. 创建支付
+async function startLinePay(ddId, token) {
+  const res = await request({
+    url: '/pay/line/create',
+    method: 'POST',
+    header: { token },
+    data: { ddId }
+  });
+  if (res.code !== 200) {
+    // res.msg 已是国际化文案,直接提示
+    uni.showToast({ title: res.msg, icon: 'none' });
+    return;
+  }
+  const { paymentUrl, status, reusedAttempt } = res.data;
+
+  // 已支付,无需跳转
+  if (status === 'PAID') {
+    goOrderResult(ddId);
+    return;
+  }
+  // 打开 LINE 收银台(reusedAttempt=true 也用同一个 url)
+  // #ifdef APP-PLUS
+  plus.runtime.openURL(paymentUrl); // 或用 webview
+  // #endif
+}
+
+// 2. Scheme 回调里(query 的入口)
+//     App scheme: com.twanmsdyh.app://payment/result?orderId=<ddId>
+function onPaymentScheme(orderId) {
+  pollLinePayQuery(orderId, getToken());
+}
+
+// 3. 轮询查询最终结果
+async function pollLinePayQuery(ddId, token) {
+  const MAX = 10, INTERVAL = 3000;
+  for (let i = 0; i < MAX; i++) {
+    const res = await request({
+      url: '/pay/line/query',
+      method: 'POST',
+      header: { token },
+      data: { ddId }
+    });
+    if (res.code !== 200) {
+      uni.showToast({ title: res.msg, icon: 'none' });
+      return;
+    }
+    const { orderPayStatus, paymentStatus } = res.data;
+
+    if (orderPayStatus === 1) {        // 已支付
+      goOrderResult(ddId);
+      return;
+    }
+    if (orderPayStatus === 2) {        // 已退款
+      goOrderResult(ddId);
+      return;
+    }
+    // 终态失败(CANCELLED_OR_EXPIRED / FAILED)也结束
+    if (['CANCELLED_OR_EXPIRED', 'FAILED'].includes(paymentStatus)) {
+      uni.showToast({ title: '支付未完成', icon: 'none' });
+      return;
+    }
+    // 否则继续等待
+    await sleep(INTERVAL);
+  }
+  // 超时:不报错,引导用户稍后回订单列表查看
+  uni.showToast({ title: '支付结果确认中,请稍后查看订单', icon: 'none' });
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+```
+
+---
+
+## 9. 注意事项 / FAQ
+
+**Q1:能不能用 Scheme 到达判断支付成功?**
+不能。Scheme 只是「回到 App」的信号,参数可伪造且不含结果。**一律以 `query` 的 `orderPayStatus === 1` 为准。**
+
+**Q2:`transactionId` 为什么是字符串?**
+它是 LINE 的 19 位交易号,超过 JS 安全整数范围(`2^53`),用 `Number` 会丢精度。解析 JSON、传参、比较全部保持字符串。
+
+**Q3:用户重复点击「去支付」会重复扣款吗?**
+不会。后端保证同订单最多一条活跃尝试,重复点击返回同一 `paymentUrl`(`reusedAttempt=true`)。
+
+**Q4:用户授权完关了页面、或没装 App、或 Scheme 没唤起怎么办?**
+不影响结果。后端定时任务会主动向 LINE 确认并落账。用户重新打开订单页调 `query` 即可拿到最终状态。前端轮询超时后**不要报错**,提示「结果确认中」即可。
+
+**Q5:为什么 `create` 报错「订单支付方式不符」?**
+`create` 要求订单当前 `payType` 必须是 `"3"`。请检查下单接口是否正确设置了 LINE Pay。
+
+**Q6:需要前端处理退款吗?**
+不需要。退款是全额退款,由后端在用户/商家**取消订单**时自动发起。前端通过 `query` 的 `orderPayStatus === 2` 或 `refundStatus === "REFUNDED"` 感知即可。
+
+**Q7:金额前端传吗?**
+不传。金额、币种(TWD)全部由服务端订单决定,前端只传 `ddId`。
+
+**Q8:`MANUAL_REVIEW` 怎么处理?**
+支付/退款结果未知且超过后端追踪期限会进入人工核对。前端展示「支付结果确认中,请联系客服」即可,不要允许用户重新发起(此时仍占用订单支付位)。
+
+---
+
+## 10. 接口速查
+
+| 接口 | 方法 | 路径 | Header | Body |
+|---|---|---|---|---|
+| 创建支付 | POST | `/pay/line/create` | `token` | `{ "ddId": "..." }` |
+| 查询支付 | POST | `/pay/line/query` | `token` | `{ "ddId": "..."` } |
+| LINE 回跳(后端) | GET | `/pay/line/confirm` | — | LINE 调用,前端不直接对接 |
+| App Scheme | — | `com.twanmsdyh.app://payment/result?orderId=<ddId>` | — | 中间页唤起 |
+
+> 域名以实际部署为准(生产公网回跳域名如 `https://foodieapi.waimai-paotui.com`),由后端配置,前端只需用相对路径 + 网关统一前缀。

+ 10 - 1
specs/020-omg-payment-rebuild/plan.md

@@ -1070,8 +1070,9 @@ Do not create an empty commit.
 | FR-030–FR-031 | Tasks 5 and 7 comment/log reviews and automated log assertions |
 | FR-030–FR-031 | Tasks 5 and 7 comment/log reviews and automated log assertions |
 | FR-032–FR-035 | T052–T057 DTO boundary, IPN transaction, snapshot signature and invariant validation |
 | FR-032–FR-035 | T052–T057 DTO boundary, IPN transaction, snapshot signature and invariant validation |
 | FR-036–FR-041 | T049/T056–T059 success-priority state machine, order-only payment update and gateway facts |
 | FR-036–FR-041 | T049/T056–T059 success-priority state machine, order-only payment update and gateway facts |
+| FR-042–FR-043 | T077–T081 old utility deletion, credential verifier migration, DROP SQL and historical-record retention |
 
 
-Self-review result: all 41 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.
+Self-review result: all 43 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.
 
 
 ## Risks and Controls
 ## Risks and Controls
 
 
@@ -1108,3 +1109,11 @@ No constitution violations. The separate form factory, signer, generator and per
 2. Service 只校验身份与订单号格式,随后稳定返回环境不支持错误。
 2. Service 只校验身份与订单号格式,随后稳定返回环境不支持错误。
 3. 代码中不引入正式 `CreditDetail/DoAction` 地址、HTTP Gateway、退款表或订单/支付更新。
 3. 代码中不引入正式 `CreditDetail/DoAction` 地址、HTTP Gateway、退款表或订单/支付更新。
 4. Controller 返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT` 及五语言提示;异常统一为 `PAYMENT_REFUND_FAILED`。
 4. Controller 返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT` 及五语言提示;异常统一为 `PAYMENT_REFUND_FAILED`。
+
+## 2026-08-13 旧实现与旧表最终清理
+
+- 删除 `com.ruoyi.app.utils.omg` 下的 `OmgPay`、`OmgPayConfig`、`OmgCheckMacValue` 及对应测试;rebuild 运行代码只使用 `com.ruoyi.app.omgpay` 和 `com.ruoyi.system.omgpay`。
+- 保留可信的 `pos_store_omg` Entity、Mapper、Service 和管理接口。原 Controller 的凭证联网探测迁入 `OmgStoreCredentialVerifier`,复用新的 `OmgCheckMacSigner`、`OmgQueryResponseParser` 与 stage-only `OmgPaymentQueryGateway`。
+- 删除旧 `omg.base-url` 配置;stage 查询地址继续由 rebuild 网关固定控制,不接受客户端或旧配置覆盖。
+- 仅在 `updatesql/sql.md` 追加按依赖顺序删除 `pos_order_omg_refund`、`pos_order_omg_payment` 的 SQL,不在实现会话中执行;保留 `pos_store_omg`、`pos_order_omg_attempt` 和 `ipn_log`。
+- 保留 `specs/016-omg-payment` 与历史建表 SQL 作为审计记录,但它们不再定义当前实现。

+ 4 - 1
specs/020-omg-payment-rebuild/spec.md

@@ -185,7 +185,7 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - **FR-024**: 业务校验错误 MUST 使用项目国际化机制,不得硬编码单一语言错误;新增错误 key 必须同步 `vi/zh/tw/en` 支持来源。
 - **FR-024**: 业务校验错误 MUST 使用项目国际化机制,不得硬编码单一语言错误;新增错误 key 必须同步 `vi/zh/tw/en` 支持来源。
 - **FR-025**: 旧 `OmgPayController` MUST 取消 Spring Controller 身份且所有旧接口不可访问;不得保留 legacy 路径。
 - **FR-025**: 旧 `OmgPayController` MUST 取消 Spring Controller 身份且所有旧接口不可访问;不得保留 legacy 路径。
 - **FR-026**: 旧 OMG 定时补单任务、订单取消链路中的旧 OMG 退款调用、旧管理端 OMG 补单/退款接口和其他对旧 Controller 的运行时调用 MUST 一并停用或移除;不得影响非 OMG 订单取消及其他支付通道。
 - **FR-026**: 旧 OMG 定时补单任务、订单取消链路中的旧 OMG 退款调用、旧管理端 OMG 补单/退款接口和其他对旧 Controller 的运行时调用 MUST 一并停用或移除;不得影响非 OMG 订单取消及其他支付通道。
-- **FR-027**: 在开发者删除旧 OMG 支付及退款表后,任何可达运行链路 MUST NOT 再查询或写入这些旧表;旧表对应的 Mapper/Service 即使暂时保留源码,也不得被新流程或当前有效业务入口调用。`pos_store_omg` 凭证表不属于该禁用范围。
+- **FR-027**: 旧 OMG 支付及退款表 `pos_order_omg_payment`、`pos_order_omg_refund` MUST 通过 `updatesql/sql.md` 登记删除,任何可达运行链路和源码 MUST NOT 再查询或写入这些旧表。`pos_store_omg` 凭证表不属于该禁用范围。
 - **FR-028**: 所有新建表、索引或约束 SQL MUST 只追加到 `updatesql/sql.md`,实现过程不得执行数据库变更。
 - **FR-028**: 所有新建表、索引或约束 SQL MUST 只追加到 `updatesql/sql.md`,实现过程不得执行数据库变更。
 - **FR-029**: 创建与回调 MUST 保持测试环境边界,验收不得把旧回调或旧查询结果当成新流程成功证据。
 - **FR-029**: 创建与回调 MUST 保持测试环境边界,验收不得把旧回调或旧查询结果当成新流程成功证据。
 - **FR-030**: 新 `omgpay` 代码 MUST 包含标准且必要的注释:公开类型说明职责和安全边界;协议字段、检查码编码、事务与数据库唯一约束等非显然逻辑说明“为什么”;不为显然的赋值、访问器或框架样板添加重复注释。注释不得包含真实凭证、完整签名原文或可用测试秘密。
 - **FR-030**: 新 `omgpay` 代码 MUST 包含标准且必要的注释:公开类型说明职责和安全边界;协议字段、检查码编码、事务与数据库唯一约束等非显然逻辑说明“为什么”;不为显然的赋值、访问器或框架样板添加重复注释。注释不得包含真实凭证、完整签名原文或可用测试秘密。
@@ -200,6 +200,8 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - **FR-039**: 合法成功通知到达时,即使订单已取消也 MUST 记录付款事实并把订单 `pay_status` 更新为 `1`;系统 MUST 输出异常日志,但本阶段不得自动退款。
 - **FR-039**: 合法成功通知到达时,即使订单已取消也 MUST 记录付款事实并把订单 `pay_status` 更新为 `1`;系统 MUST 输出异常日志,但本阶段不得自动退款。
 - **FR-040**: 一笔尝试成功后 MUST 关闭同订单其他仍活动的尝试。若历史尝试后来也收到合法成功通知,仍 MUST 记录第二笔付款事实并输出严重异常日志,不得因本地单活跃约束丢弃真实资金通知。
 - **FR-040**: 一笔尝试成功后 MUST 关闭同订单其他仍活动的尝试。若历史尝试后来也收到合法成功通知,仍 MUST 记录第二笔付款事实并输出严重异常日志,不得因本地单活跃约束丢弃真实资金通知。
 - **FR-041**: 支付尝试 MUST 保存 `trade_no`、`rtn_code`、`rtn_msg`、`payment_type`、`payment_date`、`trade_date`、`payment_type_charge_fee`、`simulate_paid` 和 `last_notify_time`;完整逐次回调原文只保存于 `ipn_log`。`trade_no` 非空时全局唯一,防止同一 OMG 交易被绑定到多个本地尝试。
 - **FR-041**: 支付尝试 MUST 保存 `trade_no`、`rtn_code`、`rtn_msg`、`payment_type`、`payment_date`、`trade_date`、`payment_type_charge_fee`、`simulate_paid` 和 `last_notify_time`;完整逐次回调原文只保存于 `ipn_log`。`trade_no` 非空时全局唯一,防止同一 OMG 交易被绑定到多个本地尝试。
+- **FR-042**: 旧 `com.ruoyi.app.utils.omg` 工具包及其测试 MUST 删除。可信门店凭证录入保留原表与管理流程,但联网验证 MUST 迁入新的 `com.ruoyi.app.omgpay`,并复用 rebuild 的签名、严格响应解析和 stage 查询边界。
+- **FR-043**: `specs/016-omg-payment` 和历史建表 SQL MUST 作为变更记录保留,但不得再作为运行实现依据;旧表删除只能追加新迁移记录,不改写或移除历史 SQL。
 
 
 ### API Contract
 ### API Contract
 
 
@@ -361,6 +363,7 @@ token: <login-token>
 - **SC-009**: 受影响的自动化测试和 JDK 21 模块构建均以退出码 0 完成。
 - **SC-009**: 受影响的自动化测试和 JDK 21 模块构建均以退出码 0 完成。
 - **SC-010**: 创建开始、创建成功、业务拒绝和非预期异常均可通过业务订单号及安全的支付尝试上下文定位;敏感信息日志泄露数为 0,非预期异常堆栈保留率为 100%。
 - **SC-010**: 创建开始、创建成功、业务拒绝和非预期异常均可通过业务订单号及安全的支付尝试上下文定位;敏感信息日志泄露数为 0,非预期异常堆栈保留率为 100%。
 - **SC-011**: 新 `omgpay` 的公开类型、签名编码、事务和数据库并发边界均有必要注释,显然样板代码上的重复注释数为 0。
 - **SC-011**: 新 `omgpay` 的公开类型、签名编码、事务和数据库并发边界均有必要注释,显然样板代码上的重复注释数为 0。
+- **SC-012**: 生产源码中旧 `com.ruoyi.app.utils.omg` 类定义、导入和调用数为 0;旧支付/退款表没有运行 SQL,测试源码只允许用类名或表名做退役断言。
 
 
 ## Assumptions
 ## Assumptions
 
 

+ 12 - 4
specs/020-omg-payment-rebuild/tasks.md

@@ -107,7 +107,7 @@
 
 
 - [x] T061 更新 `quickstart.md` 的回调重放示例、成功/失败/重复/模拟/取消后付款与 IPN 查询步骤
 - [x] T061 更新 `quickstart.md` 的回调重放示例、成功/失败/重复/模拟/取消后付款与 IPN 查询步骤
 - [x] T062 运行 `git diff --check`、定向 `rg` 和 staged diff 审计;本阶段不运行 Maven、编译或测试
 - [x] T062 运行 `git diff --check`、定向 `rg` 和 staged diff 审计;本阶段不运行 Maven、编译或测试
-- [ ] T063 在后续 OMG 查询、补单、退款等功能全部调整完成后,统一运行 JDK 21 定向测试、模块构建和完整回归
+- [x] T063 在后续 OMG 查询、补单、退款等功能全部调整完成后,统一运行 JDK 21 定向测试、模块构建和完整回归
 
 
 ## Phase 11: 当前有效支付查询与丢失回调补偿
 ## Phase 11: 当前有效支付查询与丢失回调补偿
 
 
@@ -117,7 +117,7 @@
 - [x] T067 [US5] 实现 `POST /pay/omg/query`:核验订单归属、交易号、商户号、金额和完整响应签名,只返回字段白名单
 - [x] T067 [US5] 实现 `POST /pay/omg/query`:核验订单归属、交易号、商户号、金额和完整响应签名,只返回字段白名单
 - [x] T068 [US5] 将可信 `TradeStatus=1` 复用回调同一幂等事务补偿为已付款;`0` 不修改;`10200095` 同步失败;任何结果都不得降级已付款事实
 - [x] T068 [US5] 将可信 `TradeStatus=1` 复用回调同一幂等事务补偿为已付款;`0` 不修改;`10200095` 同步失败;任何结果都不得降级已付款事实
 - [x] T069 [US5] 增加查询 i18n、必要安全日志、API/规格说明;不写 `ipn_log`,因为它不是网关主动 IPN
 - [x] T069 [US5] 增加查询 i18n、必要安全日志、API/规格说明;不写 `ipn_log`,因为它不是网关主动 IPN
-- [ ] T070 所有 OMG 功能调整完成后,统一执行本阶段测试源码、JDK 21 模块构建和完整回归
+- [x] T070 所有 OMG 功能调整完成后,统一执行本阶段测试源码、JDK 21 模块构建和完整回归
 
 
 ## Phase 12: 测试环境退款安全拒绝接口
 ## Phase 12: 测试环境退款安全拒绝接口
 
 
@@ -126,7 +126,15 @@
 - [x] T073 [US6] 在新 Controller 增加 `POST /pay/omg/refund`,返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT`,不接受金额、交易号或 Action
 - [x] T073 [US6] 在新 Controller 增加 `POST /pay/omg/refund`,返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT`,不接受金额、交易号或 Action
 - [x] T074 [US6] 增加 default、zh_CN、zh_TW、en_US、vi 一致 i18n 和脱敏拒绝日志
 - [x] T074 [US6] 增加 default、zh_CN、zh_TW、en_US、vi 一致 i18n 和脱敏拒绝日志
 - [x] T075 [US6] 更新规格与 API 契约,明确不调用正式 OMG 地址、不读取或修改订单/支付/退款状态、不新增 SQL
 - [x] T075 [US6] 更新规格与 API 契约,明确不调用正式 OMG 地址、不读取或修改订单/支付/退款状态、不新增 SQL
-- [ ] T076 所有 OMG 功能调整完成后,统一执行退款测试源码、JDK 21 模块构建和完整回归
+- [x] T076 所有 OMG 功能调整完成后,统一执行退款测试源码、JDK 21 模块构建和完整回归
+
+## Phase 13: 旧实现与旧表最终清理
+
+- [x] T077 扩展 `OmgLegacyRetirementTest`,先确认旧 `com.ruoyi.app.utils.omg` 三个类仍存在而失败
+- [x] T078 为门店凭证联网探测编写签名、完整响应验签和额外字段篡改测试,再在 `com.ruoyi.app.omgpay` 实现 `OmgStoreCredentialVerifier`
+- [x] T079 将 `PosStoreOmgController` 切换到新 verifier,删除旧工具类、旧工具测试和 `omg.base-url`;保留 `pos_store_omg` 全部凭证持久化与管理能力
+- [x] T080 在 `updatesql/sql.md` 追加删除 `pos_order_omg_refund`、`pos_order_omg_payment` 的 SQL,不执行数据库变更
+- [x] T081 静态检查源码旧包/旧表引用为零,并用 JDK 21 运行完整 Maven 回归
 
 
 ## Dependencies & Execution Order
 ## Dependencies & Execution Order
 
 
@@ -145,6 +153,6 @@
 - 官方检查码向量一致,实际发送字段无遗漏。
 - 官方检查码向量一致,实际发送字段无遗漏。
 - 新代码全在 `omgpay` 包,不引用旧 OMG 支付实现。
 - 新代码全在 `omgpay` 包,不引用旧 OMG 支付实现。
 - 旧 Controller、回调、查询、补单、退款、任务及旧表运行时引用为零。
 - 旧 Controller、回调、查询、补单、退款、任务及旧表运行时引用为零。
-- `pos_store_omg` 凭证实现未修改。
+- `pos_store_omg` 凭证表、持久化和管理能力保留;联网探测只使用新 `omgpay` 实现。
 - 日志足以定位问题且无敏感信息;非预期异常保留堆栈。
 - 日志足以定位问题且无敏感信息;非预期异常保留堆栈。
 - JDK 21 定向测试与构建通过,最终 diff 无无关改动。
 - JDK 21 定向测试与构建通过,最终 diff 无无关改动。

+ 9 - 0
updatesql/sql.md

@@ -845,3 +845,12 @@ INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_typ
 ('LINE Pay主动查询', @storePaymentMenuId, 15, '#', '', 'F', '0', '0', 'system:order:linePaymentReconcile', '#', 'admin', NOW(), ''),
 ('LINE Pay主动查询', @storePaymentMenuId, 15, '#', '', 'F', '0', '0', 'system:order:linePaymentReconcile', '#', 'admin', NOW(), ''),
 ('LINE Pay全额退款', @storePaymentMenuId, 16, '#', '', 'F', '0', '0', 'system:order:lineRefund', '#', 'admin', NOW(), '');
 ('LINE Pay全额退款', @storePaymentMenuId, 16, '#', '', 'F', '0', '0', 'system:order:lineRefund', '#', 'admin', NOW(), '');
 ```
 ```
+
+## 2026-08-13 OMG rebuild 旧支付与退款表退役
+
+```sql
+-- 仅记录脚本,不在 Codex 会话中执行。先删引用支付流水的退款表,再删旧支付流水表。
+-- 保留 pos_store_omg(门店凭证)、pos_order_omg_attempt(rebuild 支付尝试)和 ipn_log(回调原文)。
+DROP TABLE IF EXISTS pos_order_omg_refund;
+DROP TABLE IF EXISTS pos_order_omg_payment;
+```