Przeglądaj źródła

fix(payment): record OMG callback type in IPN log

qmj 2 tygodni temu
rodzic
commit
e9c367e6be

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -256,6 +256,7 @@ public class OmgPayController extends BaseController {
             IpnLog ipnLog = new IpnLog();
             ipnLog.setIp(callbackIp(forwardedFor));
             ipnLog.setIpnLog(auditJson(form));
+            ipnLog.setType("omg");
             ipnLogService.insertIpnLog(ipnLog);
         } catch (Exception e) {
             log.warn("记 OMG IPN 日志失败", e);
@@ -423,6 +424,7 @@ public class OmgPayController extends BaseController {
             IpnLog ipnLog = new IpnLog();
             ipnLog.setIp(callbackIp(forwardedFor));
             ipnLog.setIpnLog(auditJson(form));
+            ipnLog.setType("omg");
             ipnLogService.insertIpnLog(ipnLog);
         } catch (Exception e) {
             log.warn("记 OMG paymentInfo IPN 日志失败", e);

+ 26 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java

@@ -10,16 +10,19 @@ import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.utils.omg.OmgPay;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.IpnLog;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderOmgPayment;
 import com.ruoyi.system.domain.PosOrderOmgRefund;
 import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.service.IIpnLogService;
 import com.ruoyi.system.service.IPosOrderOmgPaymentService;
 import com.ruoyi.system.service.IPosOrderOmgRefundService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreOmgService;
 import jakarta.servlet.http.HttpServletRequest;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.MutablePropertyValues;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -46,6 +49,7 @@ import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -213,6 +217,28 @@ class OmgPayControllerTest {
                 request.toParameterMap());
     }
 
+    @Test
+    void omgCallbacksRecordSanitizedContentWithOmgType() {
+        OmgPayController controller = new OmgPayController();
+        IIpnLogService ipnLogService = mock(IIpnLogService.class);
+        ReflectionTestUtils.setField(controller, "ipnLogService", ipnLogService);
+
+        OmgCallbackRequest callback = new OmgCallbackRequest();
+        callback.setRtnMsg(new String[]{"received"});
+        callback.setCheckMacValue(new String[]{"signature"});
+        callback.setAuthCode(new String[]{"authorization"});
+
+        assertEquals("1|OK", controller.notify(callback, "127.0.0.1"));
+        assertEquals("1|OK", controller.paymentInfoCallback(callback, "127.0.0.1"));
+
+        ArgumentCaptor<IpnLog> captor = ArgumentCaptor.forClass(IpnLog.class);
+        verify(ipnLogService, times(2)).insertIpnLog(captor.capture());
+        for (IpnLog log : captor.getAllValues()) {
+            assertEquals("omg", log.getType());
+            assertEquals("{\"RtnMsg\":\"received\"}", log.getIpnLog());
+        }
+    }
+
     @Test
     void createsOnlyOneManualRefundTodo() {
         RefundFixture fixture = refundFixture("ATM_TAISHIN");

+ 7 - 2
ruoyi-system/src/main/resources/mapper/system/IpnLogMapper.xml

@@ -9,10 +9,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="ip"    column="ip"    />
         <result property="cretim"    column="cretim"    />
         <result property="ipnLog"    column="ipn_log"    />
+        <result property="type"    column="type"    />
     </resultMap>
 
     <sql id="selectIpnLogVo">
-        select id, ip, cretim, ipn_log from ipn_log
+        select id, ip, cretim, ipn_log, type from ipn_log
     </sql>
 
     <select id="selectIpnLogList" parameterType="IpnLog" resultMap="IpnLogResult">
@@ -20,6 +21,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <where>  
             <if test="ip != null  and ip != ''"> and ip = #{ip}</if>
             <if test="cretim != null "> and cretim = #{cretim}</if>
+            <if test="type != null  and type != ''"> and type = #{type}</if>
         </where>
     </select>
     
@@ -34,11 +36,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="ip != null">ip,</if>
             <if test="cretim != null">cretim,</if>
             <if test="ipnLog != null">ipn_log,</if>
+            <if test="type != null">type,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="ip != null">#{ip},</if>
             <if test="cretim != null">#{cretim},</if>
             <if test="ipnLog != null">#{ipnLog},</if>
+            <if test="type != null">#{type},</if>
          </trim>
     </insert>
 
@@ -48,6 +52,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="ip != null">ip = #{ip},</if>
             <if test="cretim != null">cretim = #{cretim},</if>
             <if test="ipnLog != null">ipn_log = #{ipnLog},</if>
+            <if test="type != null">type = #{type},</if>
         </trim>
         where id = #{id}
     </update>
@@ -62,4 +67,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             #{id}
         </foreach>
     </delete>
-</mapper>
+</mapper>

+ 54 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/IpnLogMapperXmlTest.java

@@ -0,0 +1,54 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.IpnLog;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.mapping.BoundSql;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class IpnLogMapperXmlTest {
+
+    @Test
+    void mapperPersistsSelectsAndFiltersType() throws IOException {
+        Configuration configuration = mapperConfiguration();
+        IpnLog query = new IpnLog();
+        query.setType("omg");
+
+        BoundSql insert = configuration
+                .getMappedStatement("com.ruoyi.system.mapper.IpnLogMapper.insertIpnLog")
+                .getBoundSql(query);
+        assertTrue(sql(insert).contains("type"));
+        assertTrue(insert.getParameterMappings().stream()
+                .anyMatch(mapping -> "type".equals(mapping.getProperty())));
+
+        BoundSql select = configuration
+                .getMappedStatement("com.ruoyi.system.mapper.IpnLogMapper.selectIpnLogList")
+                .getBoundSql(query);
+        assertTrue(sql(select).contains("select id, ip, cretim, ipn_log, type from ipn_log"));
+        assertTrue(sql(select).contains("type = ?"));
+        assertTrue(select.getParameterMappings().stream()
+                .anyMatch(mapping -> "type".equals(mapping.getProperty())));
+    }
+
+    private Configuration mapperConfiguration() throws IOException {
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("IpnLog", IpnLog.class);
+        String resource = "mapper/system/IpnLogMapper.xml";
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        return configuration;
+    }
+
+    private String sql(BoundSql boundSql) {
+        return boundSql.getSql().replaceAll("\\s+", " ").trim().toLowerCase(Locale.ROOT);
+    }
+}

+ 1 - 1
specs/016-omg-payment/plan.md

@@ -16,7 +16,7 @@
 
 **Primary Dependencies**: Spring Boot、MyBatis-Plus(@TableName/@TableId,XML mapper)、Apache HttpClient 4(幕后 POST QueryTradeInfo/DoAction)、fastjson2、`@Anonymous`/`PermitAllUrlProperties`(回调白名单)
 
-**Storage**: MySQL(新增 `pos_store_omg` / `pos_order_omg_payment` / `pos_order_omg_refund` 三表,DDL 写 `updatesql/sql.md` 不直接执行;`pos_order` 不改结构,仅扩展 `pay_type="7"`)
+**Storage**: MySQL(新增 `pos_store_omg` / `pos_order_omg_payment` / `pos_order_omg_refund` 三表,DDL 写 `updatesql/sql.md` 不直接执行;`pos_order` 不改结构,仅扩展 `pay_type="7"`;后台回调复用共享 `ipn_log` 保存脱敏内容并写 `type=omg`
 
 **Testing**: 工具类 `main` 自测(CheckMacValue 用官方示例值复算)+ 测试环境端到端(测试卡,见 quickstart.md);项目无统一自动化测试框架约定
 

+ 1 - 1
specs/016-omg-payment/spec.md

@@ -151,7 +151,7 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 - **FR-007**:系统 MUST 支持订单取消时经 OMG 退款 API(`05_refund`)退款并同步状态(本期范围,D3)。
 - **FR-008**:系统 MUST 支持门店级 OMG 凭证配置(MerchantID/HashKey/HashIV,启用开关,测试/生产切换)。
 - **FR-009**:本期功能覆盖范围**仅餐饮订单**的在线支付;旅游·机票订单(015)后续衔接,不在本期(D5)。
-- **FR-010**:系统 MUST 持久化支付流水(`MerchantTradeNo`、金额、方式、时间、回调原始报文、退款记录),支持对账。
+- **FR-010**:系统 MUST 持久化支付流水(`MerchantTradeNo`、金额、方式、时间、回调原始报文、退款记录),并将脱敏后的后台回调内容写入共享 `ipn_log`,以 `type=omg` 区分支付渠道,支持排查与对账。
 - **FR-011**:系统 MUST 在 `InvoiceMark=N` 下与 ezPay 电子发票链路解耦,支付不干涉开票。
 - **FR-012**:OMG **替代蓝新 NewebPay(011)** 作为订单在线支付启用,NewebPay 不再启用(D2)。
 - **FR-013**:系统 MUST NOT 复用或依赖任何蓝新 NewebPay 代码/表;OMG 凭证、流水、退款表与工具类/Controller 全部独立新建,仅可使用平台共享基础设施(订单状态机、推送、订单日志)。

+ 1 - 1
specs/016-omg-payment/tasks.md

@@ -74,7 +74,7 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Independent Test**: US1 发起并跳转后用测试卡付款 → OMG 回调 `/pay/omg/notify` → 订单 payStatus=1、`pos_order_omg_payment` 写 trade_no/pay_type=`Credit_CreditCard`/rtn_code=1、用户/商家/骑手收到推送;重复回调不变;金额不符/伪造签名拒绝
 
-- [x] T018 [US2] 在 `OmgPayController.java` 实现 `POST /pay/omg/notify`(`@Anonymous`):collectForm 收参 → 记 IpnLog → 由 `MerchantID` 调 `getEnabledByMerchantId` → 无凭证记录并返回
+- [x] T018 [US2] 在 `OmgPayController.java` 实现 `POST /pay/omg/notify`(`@Anonymous`):collectForm 收参 → 将脱敏回调内容写入 IpnLog(`type=omg`)→ 由 `MerchantID` 调 `getEnabledByMerchantId` → 无凭证记录并返回
 - [x] T019 [US2] notify 验签:`OmgCheckMacValue.verify(全部回调参, hashKey, hashIv)`,与回传 `CheckMacValue` 不符 → 拒绝并记录(绝不变更订单),仍回 `1|OK`
 - [x] T020 [US2] notify 幂等+金额+状态校验:按 `TradeNo` 查 `pos_order_omg_payment`(已 pay_status=1 → 直接返回 `1|OK`);`TradeAmt==订单 amount`(不符→拒绝核销、告警);由 `MerchantTradeNo` 反查流水与订单(不存在→记录待查返回);判 `RtnCode==1 且 SimulatePaid!=1`(模拟支付 `SimulatePaid=1` → 不发货、记录)
 - [x] T021 [US2] notify 成功业务链路:`markSuccess`(pay_type=回覆 PaymentType 如 `Credit_CreditCard`、rtn_code、pay_time=PaymentDate、callback_raw)→ 更新 `pos_order`(state=0, payStatus=1) → `orderLogHelper.logSync` 记日志 → `PayPush`+`pushEventService.PublisherEvent` 推送用户/商家(参照 `PosOrderController.sendHdfkMessage` 货到付款同款 `OrderPushBodyDto.getJson`)→ **补全可接单骑手推送**:在 `OmgPayController` 内独立实现(复用 `PayPush.sendAcceptRiderPush` / 货到付款链路调用方式,**不复用 NewebPay 代码**,见 CLAUDE.md「sendAcceptRiderPush 迁移」)→ 回应**纯字符串 `1|OK`**(注意:非 JSON,与蓝新不同)