plan.md 52 KB

OMG AIO 创建支付订单重建 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans for inline implementation or subagent-driven-development only when the user explicitly requests subagents. Execute tasks.md task-by-task; every production behavior must follow Red → Green → Refactor.

Goal: 从零实现测试环境 POST /pay/omg/create,按门店独立凭证生成官方 AIO 签名表单,以新尝试表阻止重复支付入口,并彻底退役旧 OMG 支付/回调/补单/退款运行时。

Architecture: ruoyi-system/com.ruoyi.system.omgpay 只负责新尝试表、锁定订单快照和 MyBatis 持久化;ruoyi-admin/com.ruoyi.app.omgpay 负责 token 用户解析、业务校验、交易号、官方检查码、表单组装、事务编排、Controller 与安全日志。创建流程不调用 OMG HTTP:事务内 SELECT pos_order ... FOR UPDATE 后检查活跃尝试,生成并落库 CREATED,提交后由 Controller 返回表单。旧 pos_store_omg 凭证查询保持不变,其余旧 OMG 支付代码不得被新流程引用。

Tech Stack: Java 21、Spring Boot 3、MyBatis/MyBatis-Plus、MySQL、JUnit 5、Mockito、SLF4J/Logback、Maven Surefire。

Global Constraints

  • OMG AIO 官方技术文件 V1.5.3 是支付协议唯一外部事实来源;不得从旧代码或 specs/016-omg-payment 复制行为。
  • 只实现创建支付订单;回调、取号通知、查询、补单、退款、关账和正式环境全部不实现。
  • 新代码只能位于 com.ruoyi.app.omgpay、com.ruoyi.system.omgpay 及对应资源/测试目录。
  • ruoyi-admin -> ruoyi-system;ruoyi-system 禁止导入 com.ruoyi.app.*。
  • 每个门店独立使用 pos_store_omg 中已启用的 MerchantID / HashKey / HashIV;不修改可信凭证存储与管理代码。
  • 新创建入口固定 POST /pay/omg/create;Controller 使用 @RequestHeader String token、显式 @RequestBody DTO,禁止 Map 入参和 Bean Validation。
  • 客户端请求只含 orderId;金额、门店、网关、ReturnURL、说明、支付方式和签名全部由服务端产生。
  • ChoosePayment=ALL、NeedExtraPaidInfo=Y;所有实际发送的非 CheckMacValue 字段全部参加检查码计算。
  • 后续回调必须对全部实际返回字段验签,包含额外字段与空值字段;本阶段只保留规格约束,不注册回调处理器。
  • 网关固定 https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5,不得配置正式环境回退。
  • 同一 ddId 最多一条 CREATED;存在时返回 PAYMENT_ATTEMPT_EXISTS,不重放旧表单、不生成新 MerchantTradeNo。
  • DDL 只追加到 updatesql/sql.md,绝不连接数据库执行。
  • 所有业务错误使用 MessageUtils.message(...);新增 key 同步 default、zh_CN、zh_TW、en_US、vi 五个 properties 文件。
  • 日志不得包含 token、HashKey、HashIV、完整 CheckMacValue、完整表单、DTO 或凭证对象;非预期异常必须以 ERROR 记录异常对象和堆栈。
  • 新公开类型、检查码编码、事务行锁与唯一约束必须有必要注释;禁止显然代码噪声注释。
  • JDK 21 只在当前命令环境设置 JAVA_HOME/PATH;不修改全局配置。
  • 工作区已有未提交修改。执行前逐文件读取现有 diff;不 reset、不覆盖、不提交与本规格无关的修改。

Summary

Create transaction

HTTP token + {orderId}
  -> OmgPaymentController resolves user
  -> OmgPaymentCreateService @Transactional
     -> SELECT pos_order by dd_id FOR UPDATE
     -> validate owner/single-store/state/payStatus/payType/amount
     -> SELECT active pos_order_omg_attempt
     -> read trusted enabled store credential
     -> generate 20-char MerchantTradeNo
     -> build all AIO fields
     -> sign every actual non-CheckMacValue field
     -> INSERT CREATED attempt
  -> transaction commits
  -> log safe success context
  -> AjaxResult.success({status,gatewayUrl,formFields})

Duplicate create

second request waits on the same pos_order row lock
  -> first transaction commits
  -> second request sees active_dd_id
  -> PAYMENT_ATTEMPT_EXISTS
  -> no generator call, no form replay, no second insert

Failure contract

Business failures throw OmgPaymentBusinessException carrying OmgPaymentErrorCode and an i18n key. Controller logs the stable code and safe context, then returns AjaxResult.error(localizedMessage, new OmgPaymentErrorResponse(code.name())). Unexpected exceptions are logged with the throwable and mapped to PAYMENT_CREATION_FAILED; internal message, SQL and stack are never returned.

Technical Context

Language/Version: Java 21 Primary Dependencies: Spring Boot 3.3.5、MyBatis/MyBatis-Plus、Hutool、SLF4J、JUnit 5/Mockito Storage: MySQL InnoDB;新增 pos_order_omg_attempt,复用 pos_order 与 pos_store_omg Testing: Maven Surefire;纯单元测试 + Mapper/DDL 合约测试 + 手动 stage/并发验收 Target Platform: Windows 开发,Spring Boot 服务部署,多实例安全由数据库锁/唯一键保证 Project Type: 多模块后端 web service Performance Goals: 创建流程无网关 HTTP;正常路径固定次数 DB 访问,目标 <1 秒 Constraints: stage-only、整数 TWD、MerchantTradeNo ≤20 ASCII 英数字、无旧表依赖、无敏感日志 Scale/Scope: 用户自己的单门店餐饮订单;一个创建端点、一张新尝试表

Branch/Date/Spec: test | 2026-08-13 | spec.md

Constitution Check

.specify/memory/constitution.md 仍是占位模板,因此以根目录 AGENTS.md 与已批准规格为门禁:

  • Controller 显式 header/body DTO,无 Map 入参、无 Bean Validation。
  • admin→system 依赖方向保持;system 只含 domain/mapper/service。
  • DDL 只写 updatesql/sql.md。
  • 支付、输入、凭证、日志通过 security-review 门禁。
  • 新表使用 InnoDB、参数化 MyBatis、生成列唯一键和 FOR UPDATE。
  • TDD,定向测试后再实现。
  • 只保留 pos_store_omg 可信能力,旧支付/退款表运行时引用清零。
  • 不实现回调、查询、退款或生产环境。

结论:无须复杂性豁免。

Project Structure

Documentation

specs/020-omg-payment-rebuild/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── tasks.md
└── contracts/
    └── api.md

New source files

ruoyi-system/src/main/java/com/ruoyi/system/omgpay/
├── domain/
│   ├── OmgPaymentAttempt.java
│   └── OmgPaymentOrderSnapshot.java
├── mapper/
│   └── OmgPaymentAttemptMapper.java
└── service/
    ├── IOmgPaymentAttemptService.java
    └── impl/
        └── OmgPaymentAttemptServiceImpl.java

ruoyi-system/src/main/resources/mapper/omgpay/
└── OmgPaymentAttemptMapper.xml

ruoyi-system/src/test/java/com/ruoyi/system/omgpay/
├── mapper/OmgPaymentAttemptMapperContractTest.java
└── service/OmgPaymentAttemptServiceTest.java

ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/
├── OmgPaymentController.java
├── OmgPaymentCreateService.java
├── OmgPaymentCreateOutcome.java
├── OmgPaymentProperties.java
├── OmgPaymentTokenUserResolver.java
├── OmgPaymentForm.java
├── OmgPaymentFormFactory.java
├── OmgCheckMacSigner.java
├── OmgMerchantTradeNoGenerator.java
├── OmgPaymentBusinessException.java
├── OmgPaymentErrorCode.java
└── dto/
    ├── OmgCreatePaymentRequest.java
    ├── OmgCreatePaymentResponse.java
    └── OmgPaymentErrorResponse.java

ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/
├── OmgCheckMacSignerTest.java
├── OmgMerchantTradeNoGeneratorTest.java
├── OmgPaymentFormFactoryTest.java
├── OmgPaymentCreateServiceTest.java
├── OmgPaymentControllerTest.java
└── OmgLegacyRetirementTest.java

Modified files

ruoyi-admin/src/main/resources/application.yml
ruoyi-admin/src/main/resources/i18n/messages.properties
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
ruoyi-admin/src/main/resources/i18n/messages_vi.properties
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
updatesql/sql.md

UserOrderController.java、OrderLifecycleService.java 和 OrderLifecycleServiceTest.java 当前已有用户未提交改动;只删除明确的旧 OMG 调用/依赖,并在最终 diff 中确认其余变化完整保留。

After old-table retirement, OrderLifecycleService constructors become:

public OrderLifecycleService(IPosOrderService posOrderService,
                             OrderService billingService,
                             OrderLogHelper orderLogHelper,
                             IUserWalletService userWalletService,
                             IPointsTransactionService pointsTransactionService) {
    this(posOrderService, billingService, orderLogHelper, userWalletService,
            pointsTransactionService, null, null);
}

@Autowired
public OrderLifecycleService(IPosOrderService posOrderService,
                             OrderService billingService,
                             OrderLogHelper orderLogHelper,
                             IUserWalletService userWalletService,
                             IPointsTransactionService pointsTransactionService,
                             IPosOrderLinePaymentService linePaymentService,
                             IPosOrderLineRefundService lineRefundService) { ... }

All updated tests use one of these signatures; no OMG payment/refund Service argument remains.

Retired old files

ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgCallbackRequest.java
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgOrderRequest.java
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgRefundOutcome.java
ruoyi-admin/src/main/java/com/ruoyi/app/task/OmgReconcileTask.java
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgQueryThrottle.java
ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgRefund.java
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgRefundMapper.java
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgRefundService.java
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImpl.java
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgRefundMapper.xml
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImplTest.java
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImplTest.java

保留 com.ruoyi.app.utils.omg.OmgPay/OmgPayConfig/OmgCheckMacValue,因为现有可信门店凭证管理 Controller 仍使用它们做录入时验证;新 omgpay 包通过测试保证对这些旧类零引用。

Component Interfaces

Persistence

public interface OmgPaymentAttemptMapper {
    OmgPaymentOrderSnapshot selectOrderForUpdate(@Param("ddId") String ddId);
    OmgPaymentAttempt selectActiveByDdId(@Param("ddId") String ddId);
    OmgPaymentAttempt selectByMerchantTradeNo(@Param("merchantTradeNo") String merchantTradeNo);
    int insertCreated(OmgPaymentAttempt attempt);
}

public interface IOmgPaymentAttemptService {
    OmgPaymentOrderSnapshot lockOrder(String ddId);
    OmgPaymentAttempt getActiveByDdId(String ddId);
    OmgPaymentAttempt getByMerchantTradeNo(String merchantTradeNo);
    OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo,
                                    Long storeId, String merchantId, Integer amount);
}

Signing and form

@Component
public final class OmgCheckMacSigner {
    public String sign(Map<String, String> fields, String hashKey, String hashIv);
}

@Component
public final class OmgMerchantTradeNoGenerator {
    public String generate(); // "OMG" + 17 uppercase alphanumeric characters
}

@Component
public final class OmgPaymentFormFactory {
    public static final String STAGE_GATEWAY_URL =
            "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5";

    public OmgPaymentForm create(String orderId, Integer amount,
                                 String merchantId, String hashKey, String hashIv,
                                 String merchantTradeNo);
}

public record OmgPaymentForm(String gatewayUrl, Map<String, String> fields) {}

The form factory creates a new ordered map containing exactly these fields before signature:

fields.put("MerchantID", merchantId);
fields.put("MerchantTradeNo", merchantTradeNo);
fields.put("MerchantTradeDate", taipeiNow);
fields.put("PaymentType", "aio");
fields.put("TotalAmount", String.valueOf(amount));
fields.put("TradeDesc", "Foodie order " + safeOrderId);
fields.put("ItemName", "Order " + safeOrderId);
fields.put("ReturnURL", properties.requireSafeReturnUrl());
fields.put("ChoosePayment", "ALL");
fields.put("EncryptType", "1");
fields.put("InvoiceMark", "N");
fields.put("NeedExtraPaidInfo", "Y");
fields.put("ExpireDate", "1");
fields.put("StoreExpireDate", "30");
fields.put("BarcodeATMExpireDate", "1");
fields.put("CheckMacValue", signer.sign(fields, hashKey, hashIv));

Orchestration

@Transactional(rollbackFor = Exception.class)
public OmgPaymentCreateOutcome create(Long userId, String orderId);

public record OmgPaymentCreateOutcome(
        OmgCreatePaymentResponse response,
        Long attemptId,
        String orderId,
        Long userId,
        Long storeId,
        Integer amount,
        String maskedMerchantTradeNo) {}

API

@Anonymous
@Auth
@PostMapping("/create")
public AjaxResult create(@RequestHeader String token,
                         @RequestBody(required = false) OmgCreatePaymentRequest request);

Do not add @RepeatSubmit: repeated clicks must reach the transactional duplicate check and return the stable PAYMENT_ATTEMPT_EXISTS result rather than a generic throttle response. This endpoint performs no external HTTP and is bounded by authentication, ownership validation and fixed DB work.


Task 1: New attempt persistence and DDL

Files:

  • Create: ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java
  • Create: ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentOrderSnapshot.java
  • Create: ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java
  • Create: ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java
  • Create: ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java
  • Create: ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml
  • Create: ruoyi-system/src/test/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapperContractTest.java
  • Create: ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java
  • Modify: updatesql/sql.md

Interfaces:

  • Consumes: pos_order and no old OMG payment/refund table.
  • Produces: the persistence interfaces listed under Component Interfaces.

  • [ ] Step 1: Write failing service validation tests

    @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()); // generated by DB, never inserted by Java
    }
    
  • [ ] Step 2: Write failing Mapper/DDL contract tests

Load the XML and updatesql/sql.md as UTF-8 strings and assert:

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)"));
  • [ ] Step 3: Run tests and observe RED

    $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
    $env:Path="$env:JAVA_HOME\bin;$env:Path"
    mvn -pl ruoyi-system -am -Dtest='OmgPaymentAttemptServiceTest,OmgPaymentAttemptMapperContractTest' -Dsurefire.failIfNoSpecifiedTests=false test
    

Expected: compilation fails because new types/resources do not exist.

  • Step 4: Add DDL exactly as specified in data-model.md

Append the dated fenced SQL block. Do not run it. Keep dd_id utf8mb4 to match pos_order; keep gateway identifiers ascii_bin.

  • Step 5: Implement domain, Mapper XML and service

The XML must use parameter binding and explicit columns:

<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>

<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>

Comment why FOR UPDATE serializes endpoint calls before MerchantTradeNo generation and why active_dd_id is omitted from INSERT.

  • Step 6: Run GREEN verification

Run the command from Step 3. Expected: both tests PASS.

  • [ ] Step 7: Commit only Task 1 files

    git add -- ruoyi-system/src/main/java/com/ruoyi/system/omgpay ruoyi-system/src/main/resources/mapper/omgpay ruoyi-system/src/test/java/com/ruoyi/system/omgpay updatesql/sql.md
    git commit -m "feat: add OMG payment attempt persistence"
    

Task 2: Official CheckMacValue signer

Files:

  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java
  • Create: ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgCheckMacSignerTest.java

Interfaces:

  • Consumes: arbitrary internal Map<String,String> plus HashKey/HashIV.
  • Produces: String sign(Map<String,String>, String, String).

  • [ ] Step 1: Write official-vector failing test

Use the exact official appendix parameters and public sample key/IV, then assert:

assertEquals("AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266",
        signer.sign(fields, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS"));
  • [ ] Step 2: Write completeness and validation failing tests

    assertNotEquals(signer.sign(fields, key, iv),
        signer.sign(withExtraField(fields, "NeedExtraPaidInfo", "Y"), key, iv));
    assertNotEquals(signer.sign(fields, key, iv),
        signer.sign(withExtraField(fields, "EmptyExtra", ""), key, iv));
    assertThrows(IllegalArgumentException.class,
        () -> signer.sign(Map.of("CheckMacValue", "caller-value"), key, iv));
    

Also prove input map is unchanged and output matches [0-9A-F]{64}.

  • [ ] Step 3: Run RED

    mvn -pl ruoyi-admin -am -Dtest='OmgCheckMacSignerTest' -Dsurefire.failIfNoSpecifiedTests=false test
    

Expected: new signer class missing.

  • [ ] Step 4: Implement the minimal signer

    public String sign(Map<String, String> fields, String hashKey, String hashIv) {
    requireSecrets(hashKey, hashIv);
    TreeMap<String, String> sorted = validatedCopy(fields);
    String query = sorted.entrySet().stream()
            .map(e -> e.getKey() + "=" + e.getValue())
            .collect(Collectors.joining("&"));
    String raw = "HashKey=" + hashKey + "&" + query + "&HashIV=" + hashIv;
    String encoded = dotNetUrlEncode(raw).toLowerCase(Locale.ROOT);
    return HexFormat.of().withUpperCase().formatHex(sha256(encoded));
    }
    

validatedCopy retains empty strings and rejects only null keys/values and CheckMacValue. Add a focused comment explaining the official .NET URL conversion; do not log raw or encoded strings.

Use these exact conversion operations after URLEncoder.encode(raw, UTF_8) and before lowercasing:

return encoded.replace("%2D", "-")
        .replace("%5F", "_")
        .replace("%2E", ".")
        .replace("%21", "!")
        .replace("%2A", "*")
        .replace("%28", "(")
        .replace("%29", ")");

Although Java already leaves some of these characters unchanged, retaining the complete official table in one method makes the protocol rule auditable.

  • Step 5: Run GREEN

Run Step 3. Expected: all signer tests PASS with official vector.

  • [ ] Step 6: Commit

    git add -- ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgCheckMacSignerTest.java
    git commit -m "feat: implement official OMG check code signer"
    

Task 3: Stage-only form factory and trade number

Files:

  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentForm.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgMerchantTradeNoGenerator.java
  • Create: ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java
  • Create: ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgMerchantTradeNoGeneratorTest.java
  • Modify: ruoyi-admin/src/main/resources/application.yml

Interfaces:

  • Consumes: order snapshot values, credential strings and the signer from Task 2.
  • Produces: exact stage URL and immutable ordered form field map.

  • [ ] Step 1: Write failing generator tests

Generate 1,000 values and assert each is unique in the sample, length 20, starts OMG, and matches [A-Z0-9]{20}.

  • Step 2: Write failing form contract tests

With a fixed Clock at 2026-08-13T07:30:23Z, assert Taipei date 2026/08/13 15:30:23, exact gateway, exact 16-key set, fixed field values and that the signer receives exactly the 15 non-check-code fields.

Also assert invalid ReturnURL cases are rejected: HTTP, wrong path, query, fragment, user-info and relative URL.

Use order IDs containing <script>, #, | and non-ASCII characters to prove TradeDesc/ItemName contain no HTML or OMG item separator, use only the sanitized ASCII reference, and remain within the official length limits.

  • [ ] Step 3: Run RED

    mvn -pl ruoyi-admin -am -Dtest='OmgMerchantTradeNoGeneratorTest,OmgPaymentFormFactoryTest' -Dsurefire.failIfNoSpecifiedTests=false test
    
  • [ ] Step 4: Implement properties, generator and form factory

OmgPaymentProperties is @Component @ConfigurationProperties(prefix="omgpay") with only returnUrl. requireSafeReturnUrl() parses URI and enforces the exact safety rules.

OmgMerchantTradeNoGenerator uses SecureRandom and alphabet 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ:

StringBuilder value = new StringBuilder("OMG");
while (value.length() < 20) {
    value.append(ALPHABET.charAt(random.nextInt(ALPHABET.length())));
}
return value.toString();

OmgPaymentFormFactory uses a defensive unmodifiable copy for response fields and does not expose secrets.

The order reference used in TradeDesc and ItemName is derived only after a real order row is found. It keeps ASCII letters and digits, drops all other characters, falls back to ORDER when empty, and is truncated to 64 characters. Therefore neither field contains HTML or OMG item separators; ItemName remains below its 120 ASCII-character display limit.

Use constructor injection without a global Clock bean:

public OmgPaymentFormFactory(OmgPaymentProperties properties, OmgCheckMacSigner signer) {
    this(properties, signer, Clock.systemUTC());
}

OmgPaymentFormFactory(OmgPaymentProperties properties,
                      OmgCheckMacSigner signer,
                      Clock clock) {
    this.properties = properties;
    this.signer = signer;
    this.clock = clock;
}

Tests in the same package use the second constructor with Clock.fixed(...).

  • Step 5: Add isolated configuration

Append:

omgpay:
  return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify

Do not read omg.base-url, omg.order-result-url or omg.payment-info-url from the new package.

  • Step 6: Run GREEN and commit

Run Step 3, then:

git add -- ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentForm.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgMerchantTradeNoGenerator.java ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgMerchantTradeNoGeneratorTest.java ruoyi-admin/src/main/resources/application.yml
git commit -m "feat: build stage-only OMG checkout forms"

Task 4: Transactional create orchestration

Files:

  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentBusinessException.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateOutcome.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgCreatePaymentResponse.java
  • Create: ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

Interfaces:

  • Consumes: IOmgPaymentAttemptService, trusted IPosStoreOmgService, form factory and generator.
  • Produces: OmgPaymentCreateOutcome create(Long userId, String orderId).

  • [ ] Step 1: Write failing validation tests

One parameterized test matrix covers null order, other owner, parentDdId != ddId, null store, state 3/4, payStatus nonzero, payType other than "2", nonpositive amount and missing credential. Assert exact OmgPaymentErrorCode, no insert and no form generation.

  • [ ] Step 2: Write failing duplicate tests

    when(attempts.lockOrder("DD-1")).thenReturn(payableOrder());
    when(attempts.getActiveByDdId("DD-1")).thenReturn(existingAttempt());
    
    OmgPaymentBusinessException error = assertThrows(
        OmgPaymentBusinessException.class,
        () -> service.create(5L, "DD-1"));
    assertEquals(PAYMENT_ATTEMPT_EXISTS, error.getCode());
    verifyNoInteractions(generator, formFactory, credentialService);
    
  • [ ] Step 3: Write failing success and collision tests

Success asserts call order: lock → active lookup → credential → generator → form → insert. Outcome contains response plus safe metadata; response contains no attempt ID or secrets.

For DuplicateKeyException, test:

  • active row now exists → PAYMENT_ATTEMPT_EXISTS and no retry;
  • trade number exists but no active row → generate a new number, rebuild form and retry, at most 3 attempts;
  • unclassified/third collision → PAYMENT_CREATION_FAILED.

  • [ ] Step 4: Run RED

    mvn -pl ruoyi-admin -am -Dtest='OmgPaymentCreateServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
    
  • [ ] Step 5: Implement the transactional service

    @Transactional(rollbackFor = Exception.class)
    public OmgPaymentCreateOutcome create(Long userId, String orderId) {
    OmgPaymentOrderSnapshot order = attempts.lockOrder(orderId);
    validateOrder(userId, orderId, order);
    if (attempts.getActiveByDdId(orderId) != null) {
        throw business(PAYMENT_ATTEMPT_EXISTS);
    }
    PosStoreOmg credential = credentials.getEnabledCredential(order.getStoreId());
    validateCredential(credential);
    return createWithBoundedTradeNumberRetries(order, credential);
    }
    

Validate before using any field. Keep the exact active check before generator invocation. Catch only DuplicateKeyException; never catch and downgrade arbitrary runtime exceptions inside the transaction.

Define the enum mapping explicitly:

AUTH_REQUIRED("omg.pay.auth.required"),
ORDER_REQUIRED("omg.pay.order.required"),
ORDER_NOT_AVAILABLE("omg.pay.order.not.available"),
MULTI_STORE_ORDER_NOT_SUPPORTED("omg.pay.multi.store.unsupported"),
ORDER_STATE_NOT_PAYABLE("omg.pay.order.state.not.payable"),
ORDER_ALREADY_PAID("omg.pay.order.already.paid"),
ORDER_AMOUNT_INVALID("omg.pay.order.amount.invalid"),
PAYMENT_TYPE_INVALID("omg.pay.payment.type.invalid"),
STORE_CREDENTIAL_UNAVAILABLE("omg.pay.credential.unavailable"),
PAYMENT_ATTEMPT_EXISTS("omg.pay.attempt.exists"),
PAYMENT_CONFIGURATION_INVALID("omg.pay.configuration.invalid"),
PAYMENT_CREATION_FAILED("omg.pay.creation.failed");

OmgPaymentBusinessException carries the enum and optional safe storeId. Failures after a real order is locked populate storeId; pre-order failures leave it null. Convert invalid ReturnURL/form configuration to PAYMENT_CONFIGURATION_INVALID without exposing its raw exception message.

Validate merchantId against [A-Za-z0-9]{1,10} and require nonblank HashKey/HashIV before form creation. After order and credential validation, emit one safe INFO event:

log.info("OMG payment validation passed orderId={}, userId={}, storeId={}",
        order.getDdId(), userId, order.getStoreId());

Do not log the credential object or any individual secret. The Controller's success log runs only after this transactional method returns through the Spring proxy, so that event represents a committed attempt rather than an uncommitted insert.

Mask the trade number for logs as the first five characters, ***, and the final four characters. Values shorter than ten characters become ***; the full value remains only in the authorized success form.

  • [ ] Step 6: Run GREEN and check package isolation

    mvn -pl ruoyi-admin -am -Dtest='OmgPaymentCreateServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
    rg -n "com\.ruoyi\.app\.pay\.OmgPayController|com\.ruoyi\.app\.utils\.omg|IPosOrderOmgPaymentService|IPosOrderOmgRefundService" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay ruoyi-system/src/main/java/com/ruoyi/system/omgpay
    

Expected: tests PASS; rg produces no matches.

  • [ ] Step 7: Commit

    git add -- ruoyi-admin/src/main/java/com/ruoyi/app/omgpay ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
    git commit -m "feat: orchestrate safe OMG payment creation"
    

Task 5: Controller, i18n and diagnostic logs

Files:

  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentTokenUserResolver.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgCreatePaymentRequest.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgPaymentErrorResponse.java
  • Create: ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java
  • Modify: five ruoyi-admin/src/main/resources/i18n/messages*.properties files

Interfaces:

  • Consumes: create service from Task 4 and project JwtUtil/@Auth.
  • Produces: public POST /pay/omg/create.

  • [ ] Step 1: Write failing Controller contract tests

Reflection assertions:

assertEquals("/pay/omg", controllerMapping);
assertEquals("/create", postMapping);
assertTrue(hasRequestHeaderNamedToken);
assertTrue(hasExplicitRequestBody);
assertEquals(Set.of("orderId"), declaredDtoProperties);
assertFalse(usesMapRequestParameter);

Behavior assertions cover missing body/orderId, resolver failure, each business exception, success, and unexpected exception. Confirm error data.status and success data shape.

  • Step 2: Write failing log-capture tests

Attach a Logback ListAppender and assert:

  • start INFO has orderId/userId;
  • success INFO has attemptId/storeId/amount/status/masked MTN;
  • business WARN has stable code;
  • unexpected ERROR contains the exact Throwable;
  • concatenated messages do not contain token, test HashKey/HashIV, 64-character CheckMacValue or full form field dump.

  • [ ] Step 3: Run RED

    mvn -pl ruoyi-admin -am -Dtest='OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test
    
  • [ ] Step 4: Add i18n keys in all five bundles

Add keys matching every OmgPaymentErrorCode.messageKey. Use these exact key sets and translations:

# messages.properties and messages_zh_CN.properties
omg.pay.auth.required=请先登录
omg.pay.order.required=订单号不能为空
omg.pay.order.not.available=订单不存在或无权操作
omg.pay.multi.store.unsupported=多门店订单暂不支持 OMG 支付
omg.pay.order.state.not.payable=当前订单状态不可支付
omg.pay.order.already.paid=订单已支付或支付状态不可用
omg.pay.order.amount.invalid=订单金额异常
omg.pay.payment.type.invalid=订单支付方式不是 OMG
omg.pay.credential.unavailable=该门店暂未启用 OMG 支付
omg.pay.attempt.exists=该订单已有待处理的支付尝试
omg.pay.configuration.invalid=OMG 支付配置无效
omg.pay.creation.failed=OMG 支付创建失败,请稍后重试

# messages_zh_TW.properties
omg.pay.auth.required=請先登入
omg.pay.order.required=訂單號不能為空
omg.pay.order.not.available=訂單不存在或無權操作
omg.pay.multi.store.unsupported=多門店訂單暫不支援 OMG 支付
omg.pay.order.state.not.payable=目前訂單狀態不可支付
omg.pay.order.already.paid=訂單已付款或付款狀態不可用
omg.pay.order.amount.invalid=訂單金額異常
omg.pay.payment.type.invalid=訂單付款方式不是 OMG
omg.pay.credential.unavailable=此門店尚未啟用 OMG 支付
omg.pay.attempt.exists=此訂單已有待處理的支付嘗試
omg.pay.configuration.invalid=OMG 支付設定無效
omg.pay.creation.failed=OMG 支付建立失敗,請稍後再試

# messages_en_US.properties
omg.pay.auth.required=Please sign in first
omg.pay.order.required=The order number is required
omg.pay.order.not.available=The order does not exist or is not available to this user
omg.pay.multi.store.unsupported=OMG Pay does not support multi-store orders yet
omg.pay.order.state.not.payable=The current order state cannot be paid
omg.pay.order.already.paid=The order is already paid or its payment state is unavailable
omg.pay.order.amount.invalid=The order amount is invalid
omg.pay.payment.type.invalid=The order payment method is not OMG Pay
omg.pay.credential.unavailable=OMG Pay is not enabled for this store
omg.pay.attempt.exists=This order already has a pending payment attempt
omg.pay.configuration.invalid=The OMG Pay configuration is invalid
omg.pay.creation.failed=The OMG Pay checkout could not be created; please try again later

# messages_vi.properties
omg.pay.auth.required=Vui lòng đăng nhập trước
omg.pay.order.required=Vui lòng nhập mã đơn hàng
omg.pay.order.not.available=Đơn hàng không tồn tại hoặc người dùng không có quyền truy cập
omg.pay.multi.store.unsupported=OMG Pay chưa hỗ trợ đơn hàng từ nhiều cửa hàng
omg.pay.order.state.not.payable=Trạng thái đơn hàng hiện tại không thể thanh toán
omg.pay.order.already.paid=Đơn hàng đã được thanh toán hoặc trạng thái thanh toán không khả dụng
omg.pay.order.amount.invalid=Số tiền đơn hàng không hợp lệ
omg.pay.payment.type.invalid=Phương thức thanh toán của đơn hàng không phải OMG Pay
omg.pay.credential.unavailable=Cửa hàng này chưa bật OMG Pay
omg.pay.attempt.exists=Đơn hàng này đã có một lần thanh toán đang chờ xử lý
omg.pay.configuration.invalid=Cấu hình OMG Pay không hợp lệ
omg.pay.creation.failed=Không thể tạo trang thanh toán OMG Pay; vui lòng thử lại sau

Keep identical key sets across default/zh_CN/zh_TW/en_US/vi and localized values in each file.

  • Step 5: Implement resolver and Controller

The Controller does not log request/response objects:

try {
    Long userId = tokenUserResolver.requireUserId(token);
    log.info("OMG payment create started orderId={}, userId={}", orderId, userId);
    OmgPaymentCreateOutcome outcome = createService.create(userId, orderId);
    log.info("OMG payment create succeeded orderId={}, userId={}, storeId={}, "
                    + "attemptId={}, amount={}, status=CREATED, merchantTradeNo={}",
            outcome.orderId(), outcome.userId(), outcome.storeId(),
            outcome.attemptId(), outcome.amount(), outcome.maskedMerchantTradeNo());
    return AjaxResult.success(outcome.response());
} catch (OmgPaymentBusinessException error) {
    log.warn("OMG payment create rejected orderId={}, userId={}, storeId={}, code={}",
            safeOrderId, safeUserId, error.getStoreId(), error.getCode());
    return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
            new OmgPaymentErrorResponse(error.getCode().name()));
} catch (Exception error) {
    log.error("OMG payment create failed orderId={}, userId={}", safeOrderId, safeUserId, error);
    return AjaxResult.error(MessageUtils.message("omg.pay.creation.failed"),
            new OmgPaymentErrorResponse("PAYMENT_CREATION_FAILED"));
}

The project @Auth remains the first invalid-token gate. Resolver defense never logs token and maps missing/invalid user ID to AUTH_REQUIRED when reached.

Before logging, sanitize the untrusted request order ID to at most 64 characters, retaining only ASCII letters, digits, - and _; use "<empty>" when nothing remains. This prevents newline/control-character log injection and unbounded log entries. Add a test with "DD-1\r\nforged=true" and assert no CR/LF appears in any captured log message.

  • Step 6: Run GREEN plus i18n key parity check

Run Step 3. Then compare all omg.pay.* keys across the five bundles; expected identical key lists.

  • [ ] Step 7: Commit

    git add -- ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentTokenUserResolver.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgCreatePaymentRequest.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgPaymentErrorResponse.java ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java ruoyi-admin/src/main/resources/i18n
    git commit -m "feat: expose logged OMG payment creation endpoint"
    

Task 6: Retire all old payment-table runtime paths

Files:

  • Create: ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java
  • Modify: UserOrderController.java, PosOrderShOprateController.java, PosOrderController.java, OrderLifecycleService.java, OrderLifecycleServiceTest.java
  • Delete: files listed under “Retired old files”

Interfaces:

  • Consumes: new OmgPaymentController; keeps OrderLifecycleService.PAY_TYPE_OMG="2" only for channel progression gate.
  • Produces: one /pay/omg/create mapping and zero old callback/query/refund/table runtime references.

  • [ ] Step 1: Capture existing dirty diffs before editing

    git diff -- ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java
    

Record that unrelated LINE/status changes remain visible after this task. Do not use checkout/reset.

  • [ ] Step 2: Write failing retirement test

    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.system.domain.PosOrderOmgPayment"));
    assertNotNull(OmgPaymentController.class.getAnnotation(RestController.class));
    

Also inspect OmgPaymentController#create annotations and assert the only exposed method under /pay/omg is /create; no notify, query, paymentInfo, return or refund handler exists in the new class.

  • [ ] Step 3: Run RED

    mvn -pl ruoyi-admin -am -Dtest='OmgLegacyRetirementTest' -Dsurefire.failIfNoSpecifiedTests=false test
    

Expected: old classes still load.

  • Step 4: Remove old Controller/task/DTO/table persistence sources

Delete only the explicit retired file list. Preserve PosStoreOmg* and old OmgPay/OmgPayConfig/OmgCheckMacValue used by credential management.

  • [ ] Step 5: Remove reachable old calls surgically

  • UserOrderController.cancelOrder: delete only the old OMG refund block after cancellation; leave other cancellation and LINE behavior intact.

  • PosOrderShOprateController.cancelOrder: delete only the old OMG refund block.

  • PosOrderController: remove old Controller/DTO injection, the three OMG reconcile/refund/manual-confirm endpoints, and the now-unused refundData helper.

  • OrderLifecycleService: remove old table imports, constructor dependencies, validateOmgReconcile, validateOmgRefund, finalizeOmgRefund, finalizeSystemOmgRefund, old buildContext table queries and manual-refund helpers. Keep PAY_TYPE_OMG="2" and the unpaid-online-order progression gate.

  • OrderLifecycleServiceTest: remove only old payment/refund service mocks and tests; update constructors while preserving LINE Pay and current order lifecycle tests.

  • application.yml: under legacy omg, retain only base-url required by trusted PosStoreOmgController credential verification; remove old return-url, order-result-url, payment-info-url, client-redirect-url, create and reconcile configuration. Keep the new omgpay.return-url added in Task 3.

  • [ ] Step 6: Run GREEN and prove zero runtime references

    mvn -pl ruoyi-admin -am -Dtest='OmgLegacyRetirementTest,OrderLifecycleServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
    rg -n --glob '*.java' --glob '*.xml' "OmgPayController|OmgReconcileTask|IPosOrderOmgPaymentService|IPosOrderOmgRefundService|PosOrderOmgPayment|PosOrderOmgRefund|pos_order_omg_payment|pos_order_omg_refund" ruoyi-admin/src/main ruoyi-system/src/main
    

Expected: tests PASS; rg returns no matches. Historical SQL and old specs are excluded deliberately.

  • Step 7: Recheck preservation and commit

Review git diff for the three initially dirty files and verify unrelated hunks remain. Then stage only task files and commit:

git add -- ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgCallbackRequest.java ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgOrderRequest.java ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgRefundOutcome.java ruoyi-admin/src/main/java/com/ruoyi/app/task/OmgReconcileTask.java ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgQueryThrottle.java ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgRefund.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgRefundMapper.java ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgRefundService.java ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImpl.java ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgRefundMapper.xml ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImplTest.java ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImplTest.java ruoyi-admin/src/main/resources/application.yml
git add -p -- ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
git commit -m "refactor: retire legacy OMG payment runtime"

For git add -p, accept only old OMG retirement hunks and reject pre-existing LINE/order-status hunks. Before commit, use git diff --cached --name-only and git diff --cached; never include unrelated pre-staged content.

Task 7: Full verification and stage acceptance handoff

Files:

  • Modify if results require: only new OMG files or directly affected retirement files.
  • Verify: all files in this feature.

Interfaces: complete feature acceptance.

  • [ ] Step 1: Run all new and affected tests

    $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
    $env:Path="$env:JAVA_HOME\bin;$env:Path"
    mvn -pl ruoyi-system -am -Dtest='OmgPaymentAttemptServiceTest,OmgPaymentAttemptMapperContractTest' -Dsurefire.failIfNoSpecifiedTests=false test
    mvn -pl ruoyi-admin -am -Dtest='OmgCheckMacSignerTest,OmgMerchantTradeNoGeneratorTest,OmgPaymentFormFactoryTest,OmgPaymentCreateServiceTest,OmgPaymentControllerTest,OmgLegacyRetirementTest,OrderLifecycleServiceTest,LinePayCancellationRaceTest' -Dsurefire.failIfNoSpecifiedTests=false test
    

Expected: exit code 0.

  • [ ] Step 2: Build admin and dependencies

    mvn -pl ruoyi-admin -am -DskipTests package
    

Expected: BUILD SUCCESS on JDK 21.

  • [ ] Step 3: Run static safety checks

    rg -n "payment\.funpoint\.com\.tw|PlatformID|PaymentInfoURL|OrderResultURL|ClientRedirectURL|ClientBackURL" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay
    rg -n "com\.ruoyi\.app\.pay\.OmgPayController|com\.ruoyi\.app\.utils\.omg|pos_order_omg_payment|pos_order_omg_refund" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay ruoyi-system/src/main/java/com/ruoyi/system/omgpay
    rg -n "HashKey|HashIV|CheckMacValue|formFields|token" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay
    

Interpret results manually: stage URL and required form names are expected; production URL, prohibited fields, old imports and logging of values are not.

  • Step 4: Check comments and log statements

Review each new public type and non-obvious signer/transaction/unique-key branch. Confirm necessary “why” comments exist and trivial assignment comments do not. Inspect every log.* argument and verify no object serialization or sensitive values.

  • [ ] Step 5: Check diff, encoding and line endings

    git diff --check
    git status --short
    git diff --stat
    git diff --name-only
    

Compare against the pre-task dirty file list. No unrelated file may be staged or rewritten; no whole-file line-ending churn.

  • Step 6: Manual DB/stage validation when developer applies DDL

Follow quickstart.md: first creation, current-page form POST, repeated request, two-request concurrency, negative cases and log review. Do not treat payment/callback as acceptance.

  • Step 7: Final verification commit if needed

If verification required small fixes, rerun Steps 1–5 and commit only those fixes:

git commit -m "test: verify OMG payment creation rebuild"

Do not create an empty commit.

Verification Gates

  1. Official signature vector equals the published SHA-256 CheckMacValue.
  2. All 15 actual pre-sign fields, including extra/expiry fields, are signer inputs; empty fields are retained generically.
  3. Success form has exactly 16 fields and the exact stage URL.
  4. Order is locked before active lookup and generator call.
  5. Repeat and normal concurrent endpoint requests produce no second MerchantTradeNo.
  6. New table unique keys exist in documented DDL; DDL was not executed.
  7. No new code references old OMG Controller, old signer/tools or old payment/refund services.
  8. No reachable main-source SQL references old payment/refund tables.
  9. pos_store_omg and credential management remain unchanged.
  10. Old /pay/omg/notify/query/paymentInfo/return/refund routes and scheduled task are absent.
  11. Logs have required safe context and unexpected exception stack, with zero secret/full-form leakage.
  12. JDK 21 tests and module build exit 0.

Spec Coverage Self-Review

Spec requirements Implemented/verified by
FR-001–FR-003 Tasks 1, 4 and package-isolation scans
FR-004–FR-005 Task 5 Controller reflection/DTO tests
FR-006–FR-007 Task 4 validation matrix and server-side amount assertions
FR-008–FR-009 Task 3 stage constant and trade-number tests
FR-010–FR-011 Tasks 1 and 4 row lock, active unique key and duplicate tests
FR-012–FR-016 Task 3 exact field-set, text, timezone and ReturnURL tests
FR-017–FR-019 Task 2 official vector, all-field mutation and empty-value tests
FR-020–FR-021 Tasks 3 and 5 response contract; quickstart.md client POST acceptance
FR-022 Task 1 DDL/Mapper contract tests
FR-023–FR-024 Task 5 log/error tests and five-bundle key parity
FR-025–FR-027 Task 6 class absence, route absence and main-source zero-match scans
FR-028 Task 1 SQL-only DDL registration
FR-029 Tasks 3, 6 and 7 stage-only/no-callback gates
FR-030–FR-031 Tasks 5 and 7 comment/log reviews and automated log assertions

Self-review result: all 31 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.

Risks and Controls

Risk Control
Duplicate payable entries FOR UPDATE on order before generator + UNIQUE(active_dd_id)
MerchantTradeNo collision UNIQUE(merchant_trade_no) + bounded three-attempt regeneration
Old table deletion breaks active paths remove Controller/task/admin/cancel/lifecycle references; main-source zero-match scan
Credentials leak response whitelist, no credential DTO logging, safe log tests
Signature omits new fields signer accepts full actual map; completeness mutation tests
Production accidentally enabled gateway constant is exact stage URL; no production property
Old callback handles new notification old Controller deleted; route absence test
User dirty work overwritten pre-edit diff capture, surgical patching, staged-file audit

Complexity Tracking

No constitution violations. The separate form factory, signer, generator and persistence service each hold one security-sensitive responsibility and are directly unit testable; none is a generic multi-provider abstraction.