ソースを参照

修复:兼容 OMG 重试无交易查询响应

qmj 2 週間 前
コミット
ff19d52d93

+ 36 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -30,13 +30,13 @@ public class OmgPaymentQueryService {
     private static final String TRADE_UNPAID = "0";
     private static final String TRADE_PAID = "1";
     private static final String TRADE_FAILED = "10200095";
+    private static final String TRADE_NOT_FOUND = "10200047";
     private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
-    private static final List<String> REQUIRED_FIELDS = List.of(
-            "MerchantID", "MerchantTradeNo", "StoreID", "TradeNo", "TradeAmt",
-            "PaymentDate", "PaymentType", "HandlingCharge", "PaymentTypeChargeFee",
-            "TradeDate", "TradeStatus", "ItemName", "CustomField1", "CustomField2",
-            "CustomField3", "CustomField4", "CheckMacValue");
+    private static final List<String> CORE_REQUIRED_FIELDS = List.of(
+            "MerchantID", "MerchantTradeNo", "TradeAmt", "TradeStatus", "CheckMacValue");
+    private static final List<String> PAID_REQUIRED_FIELDS = List.of(
+            "TradeNo", "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate");
 
     private final IOmgPaymentAttemptService attempts;
     private final OmgPaymentQueryGateway gateway;
@@ -137,7 +137,11 @@ public class OmgPaymentQueryService {
     }
 
     private void validateResponse(Map<String, String> response, OmgPaymentAttempt attempt) {
-        if (!REQUIRED_FIELDS.stream().allMatch(response::containsKey)) {
+        List<String> missingCoreFields = missingOrBlankFields(response, CORE_REQUIRED_FIELDS);
+        if (!missingCoreFields.isEmpty()) {
+            log.warn("OMG query response rejected merchantTradeNo={} reason=missing_core_fields fields={}",
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                    missingCoreFields);
             throw new IllegalArgumentException("OMG query response has missing fields");
         }
         String checkMacValue = response.get("CheckMacValue");
@@ -151,11 +155,31 @@ public class OmgPaymentQueryService {
         if (!OmgPaymentNotifyService.secureEquals(expected, checkMacValue)) {
             throw new IllegalArgumentException("OMG query response signature mismatch");
         }
+        String tradeStatus = response.get("TradeStatus");
+        String expectedAmount = TRADE_NOT_FOUND.equals(tradeStatus)
+                ? "0" : String.valueOf(attempt.getAmount());
         if (!attempt.getMerchantId().equals(response.get("MerchantID"))
                 || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
-                || !String.valueOf(attempt.getAmount()).equals(response.get("TradeAmt"))) {
+                || !expectedAmount.equals(response.get("TradeAmt"))) {
             throw new IllegalArgumentException("OMG query response identity mismatch");
         }
+        if (TRADE_PAID.equals(tradeStatus)) {
+            List<String> missingPaidFields = missingOrBlankFields(response, PAID_REQUIRED_FIELDS);
+            if (!missingPaidFields.isEmpty()) {
+                log.warn("OMG paid query response rejected merchantTradeNo={} "
+                                + "reason=missing_settlement_fields fields={}",
+                        OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                        missingPaidFields);
+                throw new IllegalArgumentException("OMG paid query response has missing fields");
+            }
+        }
+    }
+
+    private static List<String> missingOrBlankFields(Map<String, String> response,
+                                                     List<String> requiredFields) {
+        return requiredFields.stream()
+                .filter(field -> isBlank(response.get(field)))
+                .toList();
     }
 
     private String synchronizeIfFinal(String tradeStatus, Map<String, String> fields,
@@ -169,7 +193,9 @@ public class OmgPaymentQueryService {
         if (TRADE_UNPAID.equals(tradeStatus)) {
             return "UNPAID";
         }
-        if (!TRADE_PAID.equals(tradeStatus) && !TRADE_FAILED.equals(tradeStatus)) {
+        if (!TRADE_PAID.equals(tradeStatus)
+                && !TRADE_FAILED.equals(tradeStatus)
+                && !TRADE_NOT_FOUND.equals(tradeStatus)) {
             return "UNKNOWN";
         }
         OmgPaymentSettlementResult result = settlement.synchronizeVerifiedQuery(
@@ -190,14 +216,14 @@ public class OmgPaymentQueryService {
             throw new IllegalArgumentException("paid query response has no PaymentType");
         }
         BigDecimal fee = parseDecimal(fields.get("PaymentTypeChargeFee"));
-        if (fee == null || fee.signum() < 0) {
+        if ((paid && fee == null) || (fee != null && fee.signum() < 0)) {
             throw new IllegalArgumentException("invalid payment fee");
         }
         return new OmgPaymentGatewayFacts(OmgPaymentFactSource.QUERY,
                 attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
                 tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
                 parseDate(fields.get("PaymentDate"), paid),
-                parseDate(fields.get("TradeDate"), true), fee, null);
+                parseDate(fields.get("TradeDate"), paid), fee, null);
     }
 
     private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {

+ 111 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java

@@ -91,6 +91,64 @@ class OmgPaymentQueryServiceTest {
         verify(settlement).synchronizeVerifiedQuery(argThat(facts -> facts.resultCode() == 10200095));
     }
 
+    @Test
+    void acceptsSignedSparseUnpaidResponseWithoutOptionalFields() {
+        when(gateway.query(anyMap())).thenReturn(signedSparseResponse("0"));
+
+        OmgQueryPaymentResponse response = service.query(5L, "DD-1");
+
+        assertEquals("UNPAID", response.status());
+        assertEquals("0", response.tradeStatus());
+        assertEquals(100, response.amount());
+        verify(settlement, never()).synchronizeVerifiedQuery(any());
+    }
+
+    @Test
+    void acceptsSignedSparseFailureResponseWithoutSettlementFields() {
+        when(gateway.query(anyMap())).thenReturn(signedSparseResponse("10200095"));
+        when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.FAILED);
+
+        OmgQueryPaymentResponse response = service.query(5L, "DD-1");
+
+        assertEquals("FAILED", response.status());
+        verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
+                facts.resultCode() == 10200095
+                        && facts.tradeNo() == null
+                        && facts.paymentType() == null
+                        && facts.tradeDate() == null
+                        && facts.paymentTypeChargeFee() == null));
+    }
+
+    @Test
+    void treatsSignedGatewayMissingTradeAsFailedAttempt() {
+        when(gateway.query(anyMap())).thenReturn(signedFields(gatewayMissingTradeFields()));
+        when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.FAILED);
+
+        OmgQueryPaymentResponse response = service.query(5L, "DD-1");
+
+        assertEquals("FAILED", response.status());
+        assertEquals("10200047", response.tradeStatus());
+        assertEquals(0, response.amount());
+        verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
+                facts.resultCode() == 10200047
+                        && facts.amount() == 100
+                        && facts.tradeNo() == null
+                        && facts.paymentType() == null));
+    }
+
+    @Test
+    void rejectsGatewayMissingTradeStatusWithNonzeroAmount() {
+        Map<String, String> fields = gatewayMissingTradeFields();
+        fields.put("TradeAmt", "100");
+        when(gateway.query(anyMap())).thenReturn(signedFields(fields));
+
+        OmgPaymentBusinessException exception = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.query(5L, "DD-1"));
+
+        assertEquals(OmgPaymentErrorCode.PAYMENT_QUERY_FAILED, exception.getCode());
+        verifyNoInteractions(settlement);
+    }
+
     @Test
     void paidOrderQueriesItsPaidAttemptInsteadOfAnOldOrClientSelectedAttempt() {
         OmgPaymentAttempt paid = attempt(1);
@@ -134,6 +192,25 @@ class OmgPaymentQueryServiceTest {
         verify(settlement, never()).synchronizeVerifiedQuery(any());
     }
 
+    @Test
+    void rejectsSparseResponseWithoutCoreIdentityAndPaidSettlementFields() {
+        Map<String, String> missingAmount = sparseFields("0");
+        missingAmount.remove("TradeAmt");
+        when(gateway.query(anyMap())).thenReturn(signedFields(missingAmount));
+
+        OmgPaymentBusinessException missingIdentity = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.query(5L, "DD-1"));
+        assertEquals(OmgPaymentErrorCode.PAYMENT_QUERY_FAILED, missingIdentity.getCode());
+
+        reset(gateway, settlement);
+        when(gateway.query(anyMap())).thenReturn(signedSparseResponse("1"));
+
+        OmgPaymentBusinessException missingSettlement = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.query(5L, "DD-1"));
+        assertEquals(OmgPaymentErrorCode.PAYMENT_QUERY_FAILED, missingSettlement.getCode());
+        verifyNoInteractions(settlement);
+    }
+
     private String signedResponse(String tradeStatus, Map<String, String> extras) {
         Map<String, String> fields = new LinkedHashMap<>();
         fields.put("MerchantID", "1000031");
@@ -153,6 +230,40 @@ class OmgPaymentQueryServiceTest {
         fields.put("CustomField3", "");
         fields.put("CustomField4", "");
         fields.putAll(extras);
+        return signedFields(fields);
+    }
+
+    private String signedSparseResponse(String tradeStatus) {
+        return signedFields(sparseFields(tradeStatus));
+    }
+
+    private Map<String, String> sparseFields(String tradeStatus) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", "1000031");
+        fields.put("MerchantTradeNo", "OMG123");
+        fields.put("TradeAmt", "100");
+        fields.put("TradeStatus", tradeStatus);
+        return fields;
+    }
+
+    private Map<String, String> gatewayMissingTradeFields() {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", "1000031");
+        fields.put("MerchantTradeNo", "OMG123");
+        fields.put("TradeNo", "");
+        fields.put("TradeAmt", "0");
+        fields.put("PaymentDate", "");
+        fields.put("PaymentType", "");
+        fields.put("HandlingCharge", "0");
+        fields.put("PaymentTypeChargeFee", "0");
+        fields.put("TradeDate", "");
+        fields.put("TradeStatus", "10200047");
+        fields.put("ItemName", "");
+        return fields;
+    }
+
+    private String signedFields(Map<String, String> source) {
+        Map<String, String> fields = new LinkedHashMap<>(source);
         fields.put("CheckMacValue", new OmgCheckMacSigner().sign(fields, HASH_KEY, HASH_IV));
         return fields.entrySet().stream()
                 .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))

+ 42 - 0
specs/020-omg-payment-rebuild/plan.md

@@ -1189,3 +1189,45 @@ mvn -pl ruoyi-admin -am -DskipTests package
 - [x] 静态检查源码中只存在预期渠道参数,`git diff --check` 无错误,暂存清单只包含本阶段源码、测试和文档;不包含 `.claude/homunculus/*`。
 - [x] 将 T088-T091 标记为完成;T092 保持未完成,等待部署到 OMG stage 后人工查看收银台。
 - [x] 使用中文提交信息提交:`修复:调整 OMG 收银台支付渠道`。
+
+## 2026-08-14 retry 查询稀疏响应修复计划
+
+**Goal:** 修复用户连续点击支付时,retry 查询到已签名但省略非核心字段的 OMG 响应而错误返回 `PAYMENT_QUERY_FAILED`。
+
+**Architecture:** 保持 `/pay/omg/query` 与 `/pay/omg/retry` 接口不变。查询服务对所有实际字段验签,并将核心身份字段与结算字段分层校验:任何状态都必须核对商户号、交易号、金额、状态和签名;只有 `PAID` 必须具备完整结算字段,`UNPAID/10200095/10200047` 可省略非核心字段。OMG stage 对从未提交的表单返回签名有效的 `TradeStatus=10200047` 与 `TradeAmt=0`,该组合按“网关不存在交易”同步失败并进入既有 retry 新建表单流程;其他状态继续要求金额与本地尝试一致。
+
+**Tech Stack:** Java 21、Spring Boot、JUnit 5、Mockito、Maven、OMG AIO stage。
+
+### Task 1: 稀疏响应回归测试
+
+**Files:**
+
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java`
+
+**Interfaces:**
+
+- Consumes: `OmgPaymentQueryService#query(Long userId, String orderId)`。
+- Produces: 对已签名的稀疏 `TradeStatus=0/10200095` 响应分别返回 `UNPAID/FAILED`;对 stage 实际返回的 `TradeStatus=10200047 + TradeAmt=0` 返回 `FAILED`;缺少核心字段、签名错误、`10200047` 金额非零或已付款结算字段缺失时仍返回 `PAYMENT_QUERY_FAILED`。
+
+- [x] 先增加稀疏未付款/失败响应测试,并运行 `OmgPaymentQueryServiceTest` 观察旧实现因固定 17 字段校验而失败。
+- [x] 增加缺少核心金额、已付款缺少结算字段的 fail-closed 回归断言。
+- [x] 增加 stage 实际 `10200047 + TradeAmt=0` 响应与非零金额篡改的回归断言,并观察现有金额校验导致前者失败。
+
+### Task 2: 状态感知校验与验证
+
+**Files:**
+
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java`
+- Modify: `specs/020-omg-payment-rebuild/spec.md`
+- Modify: `specs/020-omg-payment-rebuild/tasks.md`
+
+**Interfaces:**
+
+- Consumes: `OmgQueryResponseParser#parse(String)` 返回的完整实际字段集合。
+- Produces: 核心字段校验、完整实际字段验签、按 `TradeStatus` 校验结算字段,以及仅包含字段名称的安全诊断日志。
+
+- [x] 将固定字段存在性校验改为核心字段校验;验签仍覆盖解析得到的全部实际字段。
+- [x] 仅对 `TradeStatus=1` 强制要求支付交易号、支付方式、付款时间、建单时间和通路费;失败状态允许这些字段不存在。
+- [x] 仅在签名和身份验证通过且 `TradeStatus=10200047 + TradeAmt=0` 时同步旧尝试失败,使 retry 复用既有 `FAILED -> create` 流程。
+- [x] 使用 JDK 21 运行查询定向测试、全部 OMG 回归与 `ruoyi-admin` 模块构建。
+- [x] 执行 `git diff --check`、最终差异和暂存范围检查,不包含 `.claude/homunculus/*`。

+ 5 - 0
specs/020-omg-payment-rebuild/spec.md

@@ -165,6 +165,11 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - **FR-048**: 替换未付款尝试 MUST 在同一事务内锁定订单,精确比较 query 已验证的 `MerchantTradeNo` 与当前活动尝试,把该行原子更新为 `SUPERSEDED` 后再生成新交易号和新表单;任一条件变化 MUST 返回 `PAYMENT_RETRY_NOT_AVAILABLE`。
 - **FR-049**: 同一旧尝试的顺序或并发 retry MUST 最多生成一条新 `CREATED` 尝试;迟到的旧尝试可信成功回调仍 MUST 能升级为 `PAID` 并关闭更新的活动尝试。
 - **FR-050**: retry 不保存、恢复或复用 App WebView,不返回旧表单或所谓继续付款 URL;成功响应 MUST 与 create 相同并包含新 `MerchantTradeNo` 的新表单。
+- **FR-051**: 查询响应 MUST 先验证所有实际回传字段的 `CheckMacValue`,并始终核对 `MerchantID`、`MerchantTradeNo`、`TradeAmt` 与 `TradeStatus`;缺少任一核心字段时 MUST fail closed。除 `10200047` 外,`TradeAmt` MUST 与本地尝试金额一致。
+- **FR-052**: `StoreID`、`TradeNo`、`PaymentDate`、`PaymentType`、`HandlingCharge`、`PaymentTypeChargeFee`、`TradeDate`、`ItemName` 与 `CustomField1..4` 不得仅因字段不存在而使 `UNPAID`、`10200095` 或 `10200047` 查询失败;实际存在的字段仍 MUST 参与验签。
+- **FR-053**: `TradeStatus=1` MUST 继续要求可信结算所需的 `TradeNo`、`PaymentDate`、`PaymentType`、`PaymentTypeChargeFee` 与 `TradeDate`,不得因兼容稀疏未付款响应而放宽已付款事实校验。
+- **FR-054**: 查询字段缺失日志 MAY 记录缺失字段名称,但 MUST NOT 记录完整网关响应、`CheckMacValue`、HashKey、HashIV 或登录 token。
+- **FR-055**: 当已签名查询响应的商户号和交易号精确匹配、`TradeStatus=10200047` 且 `TradeAmt=0` 时,系统 MUST 将其视为网关不存在该交易并同步旧尝试失败,使 retry 创建新表单;该状态返回非零金额或任一身份、签名校验失败时 MUST fail closed。
 
 - **FR-001**: 系统 MUST 新建 `com.ruoyi.app.omgpay` 下的 Controller、请求/响应 DTO、创建服务、表单生成器、签名器和配置类型;这些新类 MUST NOT 引用旧 `OmgPayController`、旧 `OmgPay`、旧 `OmgCheckMacValue` 或旧 OMG 支付流水服务。
 - **FR-002**: 系统 MUST 新建 `com.ruoyi.system.omgpay` 下的支付尝试 Entity、Mapper 和 Service;新支付尝试 MUST 使用 `pos_order_omg_attempt`,不得读取或写入旧 OMG 支付流水表。

+ 9 - 0
specs/020-omg-payment-rebuild/tasks.md

@@ -153,6 +153,15 @@
 - [x] T091 [US1] 使用 JDK 21 运行表单定向测试、OMG 回归和模块构建,并检查签名字段集合
 - [ ] T092 [US1] 在 OMG stage 手动确认信用卡与 Apple Pay 保留、ATM/CVS/BarcodeATM 隐藏,并记录 AFTEE 在门店开通时的实际展示结果
 
+## Phase 16: retry 查询稀疏响应兼容
+
+- [x] T093 [US7] 为已签名但省略非核心字段的 `TradeStatus=0/10200095` 响应编写回归测试,并观察固定字段校验导致测试失败
+- [x] T094 [US7] 将查询校验拆分为所有状态必需的核心身份字段与仅 `PAID` 必需的结算字段;所有实际字段继续参与验签
+- [x] T095 [US7] 增加缺少核心字段、签名错误和已付款缺少结算字段的 fail-closed 测试,并确保诊断日志不记录字段值或完整响应
+- [x] T096 [US7] 按 OMG stage 实际响应增加 `10200047 + TradeAmt=0` 回归测试,并验证非零金额时 fail closed
+- [x] T097 [US7] 将签名有效且身份匹配的 `10200047 + TradeAmt=0` 同步为失败,释放旧尝试并复用 retry 新表单流程
+- [x] T098 使用 JDK 21 运行查询定向测试、全部 OMG 回归、模块构建与最终 diff/暂存范围检查
+
 ## Dependencies & Execution Order
 
 - Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10。