Răsfoiți Sursa

feat: add OMG payment attempt persistence

qmj 2 săptămâni în urmă
părinte
comite
2e1cc4565c

+ 29 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java

@@ -0,0 +1,29 @@
+package com.ruoyi.system.omgpay.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@TableName("pos_order_omg_attempt")
+public class OmgPaymentAttempt {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String ddId;
+    private String merchantTradeNo;
+    private Long storeId;
+    private String merchantId;
+    private Integer amount;
+    private Integer attemptStatus;
+    private String activeDdId;
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+}

+ 16 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentOrderSnapshot.java

@@ -0,0 +1,16 @@
+package com.ruoyi.system.omgpay.domain;
+
+import lombok.Data;
+
+@Data
+public class OmgPaymentOrderSnapshot {
+    private Long id;
+    private String ddId;
+    private String parentDdId;
+    private Long storeId;
+    private Long userId;
+    private Integer amount;
+    private Long state;
+    private Long payStatus;
+    private String payType;
+}

+ 11 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java

@@ -0,0 +1,11 @@
+package com.ruoyi.system.omgpay.mapper;
+
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import org.apache.ibatis.annotations.Param;
+
+public interface OmgPaymentAttemptMapper {
+    OmgPaymentOrderSnapshot selectOrderForUpdate(@Param("ddId") String ddId);
+    OmgPaymentAttempt selectActiveCreatedByDdId(@Param("ddId") String ddId);
+    int insertCreated(OmgPaymentAttempt row);
+}

+ 11 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java

@@ -0,0 +1,11 @@
+package com.ruoyi.system.omgpay.service;
+
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+
+public interface IOmgPaymentAttemptService {
+    OmgPaymentOrderSnapshot selectOrderForUpdate(String ddId);
+    OmgPaymentAttempt selectActiveCreatedByDdId(String ddId);
+    OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
+                                    String merchantId, Integer amount);
+}

+ 72 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java

@@ -0,0 +1,72 @@
+package com.ruoyi.system.omgpay.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.mapper.OmgPaymentAttemptMapper;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+@Service
+public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
+
+    private static final int ATTEMPT_STATUS_CREATED = 0;
+
+    private final OmgPaymentAttemptMapper mapper;
+
+    public OmgPaymentAttemptServiceImpl(OmgPaymentAttemptMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    @Override
+    public OmgPaymentOrderSnapshot selectOrderForUpdate(String ddId) {
+        return StrUtil.isBlank(ddId) ? null : mapper.selectOrderForUpdate(ddId.trim());
+    }
+
+    @Override
+    public OmgPaymentAttempt selectActiveCreatedByDdId(String ddId) {
+        return StrUtil.isBlank(ddId) ? null : mapper.selectActiveCreatedByDdId(ddId.trim());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
+                                           String merchantId, Integer amount) {
+        validateCreatedFacts(ddId, merchantTradeNo, storeId, merchantId, amount);
+        Date now = new Date();
+        OmgPaymentAttempt row = new OmgPaymentAttempt();
+        row.setDdId(ddId.trim());
+        row.setMerchantTradeNo(merchantTradeNo.trim());
+        row.setStoreId(storeId);
+        row.setMerchantId(merchantId.trim());
+        row.setAmount(amount);
+        row.setAttemptStatus(ATTEMPT_STATUS_CREATED);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
+        mapper.insertCreated(row);
+        return row;
+    }
+
+    private static void validateCreatedFacts(String ddId, String merchantTradeNo, Long storeId,
+                                             String merchantId, Integer amount) {
+        if (StrUtil.isBlank(ddId)) {
+            throw new ServiceException("OMG payment ddId is required");
+        }
+        if (StrUtil.isBlank(merchantTradeNo)) {
+            throw new ServiceException("OMG payment merchantTradeNo is required");
+        }
+        if (storeId == null) {
+            throw new ServiceException("OMG payment storeId is required");
+        }
+        if (StrUtil.isBlank(merchantId)) {
+            throw new ServiceException("OMG payment merchantId is required");
+        }
+        if (amount == null || amount <= 0) {
+            throw new ServiceException("OMG payment amount must be positive");
+        }
+    }
+}

+ 37 - 0
ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.omgpay.mapper.OmgPaymentAttemptMapper">
+
+    <sql id="attemptColumns">
+        id, dd_id AS ddId, merchant_trade_no AS merchantTradeNo,
+        store_id AS storeId, merchant_id AS merchantId, amount,
+        attempt_status AS attemptStatus, active_dd_id AS activeDdId,
+        create_time AS createTime, update_time AS updateTime
+    </sql>
+
+    <!-- FOR UPDATE serializes concurrent endpoint calls before MerchantTradeNo generation/reuse decisions. -->
+    <select id="selectOrderForUpdate"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot">
+        SELECT id, dd_id AS ddId, parent_dd_id AS parentDdId, md_id AS storeId,
+               user_id AS userId, amount, state, pay_status AS payStatus, pay_type AS payType
+        FROM pos_order WHERE dd_id = #{ddId} LIMIT 1 FOR UPDATE
+    </select>
+
+    <select id="selectActiveCreatedByDdId"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
+        SELECT <include refid="attemptColumns"/>
+        FROM pos_order_omg_attempt
+        WHERE active_dd_id = #{ddId}
+        LIMIT 1
+    </select>
+
+    <!-- active_dd_id is a DB-generated key invariant, so Java inserts only the created facts. -->
+    <insert id="insertCreated" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO pos_order_omg_attempt
+          (dd_id, merchant_trade_no, store_id, merchant_id, amount,
+           attempt_status, create_time, update_time)
+        VALUES
+          (#{ddId}, #{merchantTradeNo}, #{storeId}, #{merchantId}, #{amount},
+           #{attemptStatus}, #{createTime}, #{updateTime})
+    </insert>
+</mapper>

+ 57 - 0
ruoyi-system/src/test/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapperContractTest.java

@@ -0,0 +1,57 @@
+package com.ruoyi.system.omgpay.mapper;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OmgPaymentAttemptMapperContractTest {
+
+    @Test
+    void mapperAndSqlOnlyReferenceNewAttemptStorage() throws IOException {
+        String xml = loadMapperXml();
+        String sql = loadSql();
+
+        assertTrue(xml.contains("FROM pos_order"));
+        assertTrue(xml.contains("FOR UPDATE"));
+        assertTrue(xml.contains("FROM pos_order_omg_attempt"));
+        assertFalse(xml.contains("pos_order_omg_payment"));
+        assertFalse(xml.contains("pos_order_omg_refund"));
+        assertTrue(sql.contains("UNIQUE KEY uk_omg_attempt_trade_no"));
+        assertTrue(sql.contains("UNIQUE KEY uk_omg_attempt_active_dd"));
+        assertTrue(sql.contains("IF(attempt_status = 0, dd_id, NULL)"));
+    }
+
+    private String loadMapperXml() throws IOException {
+        try (InputStream inputStream = getClass().getClassLoader()
+                .getResourceAsStream("mapper/omgpay/OmgPaymentAttemptMapper.xml")) {
+            return new String(Objects.requireNonNull(inputStream,
+                    "mapper xml not found").readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
+    private String loadSql() throws IOException {
+        for (Path candidate : sqlCandidates()) {
+            if (Files.exists(candidate)) {
+                return Files.readString(candidate, StandardCharsets.UTF_8);
+            }
+        }
+        throw new IOException("updatesql/sql.md not found");
+    }
+
+    private List<Path> sqlCandidates() {
+        return List.of(
+                Paths.get("updatesql", "sql.md"),
+                Paths.get("..", "updatesql", "sql.md")
+        );
+    }
+}

+ 50 - 0
ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java

@@ -0,0 +1,50 @@
+package com.ruoyi.system.omgpay.service;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.mapper.OmgPaymentAttemptMapper;
+import com.ruoyi.system.omgpay.service.impl.OmgPaymentAttemptServiceImpl;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class OmgPaymentAttemptServiceTest {
+
+    @Mock
+    private OmgPaymentAttemptMapper mapper;
+
+    @InjectMocks
+    private OmgPaymentAttemptServiceImpl service;
+
+    @Test
+    void createCreatedRejectsInvalidSnapshotsBeforeInsert() {
+        assertThrows(ServiceException.class,
+                () -> service.createCreated("DD-1", "OMG123", 10L, "M1", 0));
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void createCreatedInsertsOnlyCreatedFacts() {
+        when(mapper.insertCreated(any())).thenAnswer(invocation -> {
+            OmgPaymentAttempt row = invocation.getArgument(0);
+            row.setId(7L);
+            return 1;
+        });
+
+        OmgPaymentAttempt row = service.createCreated("DD-1", "OMG123", 10L, "M1", 100);
+
+        assertEquals(0, row.getAttemptStatus());
+        assertEquals(7L, row.getId());
+        assertNull(row.getActiveDdId());
+    }
+}

+ 24 - 0
updatesql/sql.md

@@ -677,6 +677,30 @@ SELECT dd_id, COUNT(*) AS cnt FROM pos_order_omg_payment WHERE is_active=1 AND p
 GROUP BY dd_id HAVING COUNT(*)>1;
 ```
 
+## 2026-08-13 OMG 支付重做 Task 1:支付尝试表
+
+```sql
+-- 仅记录脚本,不在 Codex 会话中执行。dd_id 保持 utf8mb4 与 pos_order 一致;网关标识使用 ascii_bin。
+CREATE TABLE pos_order_omg_attempt (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  dd_id VARCHAR(64) NOT NULL,
+  merchant_trade_no VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  store_id BIGINT NOT NULL,
+  merchant_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  amount INT NOT NULL,
+  attempt_status TINYINT NOT NULL COMMENT '0=CREATED',
+  active_dd_id VARCHAR(64)
+    GENERATED ALWAYS AS (IF(attempt_status = 0, dd_id, NULL)) VIRTUAL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_omg_attempt_trade_no (merchant_trade_no),
+  UNIQUE KEY uk_omg_attempt_active_dd (active_dd_id),
+  KEY idx_omg_attempt_dd_time (dd_id, create_time, id),
+  KEY idx_omg_attempt_store_time (store_id, create_time, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OMG 支付尝试追加式账本';
+```
+
 ## 2026-08-12 LINE Pay 直连支付(019-line-pay)
 
 ```sql