# 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](tasks.md) task-by-task; steps use checkbox syntax for tracking, and every production behavior must follow Red → Green → Refactor. **Goal:** 从零实现测试环境 `POST /pay/omg/create` 和 `POST /pay/omg/notify`,按门店独立凭证创建官方 AIO 表单,并以凭证快照可信、幂等地同步最终付款结果。 **Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 负责新尝试表、订单锁、状态 CAS、凭证快照和 `ipn_log` 独立事务;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 创建/查询/退款边界、外部表单、官方检查码、回调/查询校验、事务编排与日志。创建流程保存实际密钥快照;回调和可信查询结果共同进入同一不可逆支付状态机;退款在测试阶段由独立 Service 失败关闭,不读取或写入支付事实,也不连接正式网关。旧 `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。 - create/retry 请求只含 `orderId` 与受控 `paymentMethod`;金额、门店、网关、ReturnURL、说明、OMG 原始支付参数和签名全部由服务端产生。 - `paymentMethod` 仅允许 `CREDIT/APPLE_PAY`:分别映射为 `ChoosePayment=Credit + UnionPay=2` 与 `ChoosePayment=ApplePay`。禁止 `ChoosePayment=ALL` 和 `IgnorePayment`;所有实际发送的非 `CheckMacValue` 字段全部参加检查码计算。 - 回调必须对全部实际返回字段验签,包含未知额外字段与空值字段,只有 `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 或凭证对象;回调按已批准策略直接记录完整 HTTP 回传与 CheckMacValue,但任何日志不得包含数据库中的 HashKey/HashIV。 - 新公开类型、检查码编码、事务行锁与唯一约束必须有必要注释;禁止显然代码噪声注释。 - 当前回调阶段只编写测试源码并做静态审计;Maven、编译和测试留到 OMG 全部功能调整完毕后统一执行。 - 工作区已有未提交修改。执行前逐文件读取现有 diff;不 reset、不覆盖、不提交与本规格无关的修改。 --- ## Summary ### Create transaction ```text 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 ```text 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. ### Notify transaction ```text raw application/x-www-form-urlencoded request -> explicit OmgNotifyRequest boundary preserves raw body + all fields + empty values + IP -> OmgIpnAuditService REQUIRES_NEW inserts ipn_log(type=omg) -> OmgPaymentNotifyService @Transactional -> SELECT attempt BY MerchantTradeNo FOR UPDATE -> sign every actual field except CheckMacValue with snapshot HashKey/HashIV -> verify MerchantID and TradeAmt against attempt snapshot -> RtnCode=1: CREATED/FAILED -> PAID, supersede other CREATED, pos_order.pay_status -> 1 -> RtnCode!=1: CREATED/FAILED -> FAILED, order remains unpaid -> verified/idempotent result: text/plain 1|OK -> untrusted/transaction failure: text/plain 0|ERROR ``` ## 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](spec.md) ## Constitution Check `.specify/memory/constitution.md` 仍是占位模板,因此以根目录 `AGENTS.md` 与已批准规格为门禁: - [x] Controller 显式 header/body DTO,无 Map 入参、无 Bean Validation。 - [x] admin→system 依赖方向保持;system 只含 domain/mapper/service。 - [x] DDL 只写 `updatesql/sql.md`。 - [x] 支付、输入、凭证、日志通过 security-review 门禁。 - [x] 新表使用 InnoDB、参数化 MyBatis、生成列唯一键和 `FOR UPDATE`。 - [x] 测试源码先于生产代码;按用户要求当前阶段不运行 Maven/编译/测试。 - [x] 只保留 `pos_store_omg` 可信能力,旧支付/退款表运行时引用清零。 - [x] 实现最终付款回调;不实现取号、查询、退款、推送或生产环境。 结论:无须复杂性豁免。 ## Project Structure ### Documentation ```text specs/020-omg-payment-rebuild/ ├── spec.md ├── plan.md ├── research.md ├── data-model.md ├── quickstart.md ├── tasks.md └── contracts/ └── api.md ``` ### New source files ```text 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 ```text 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: ```java 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 ```text 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 ```java 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 ```java @Component public final class OmgCheckMacSigner { public String sign(Map 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 fields) {} ``` The form factory creates a new ordered map containing exactly these fields before signature: ```java 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("OrderResultURL", properties.requireSafeOrderResultUrl()); fields.put("ChoosePayment", "ALL"); fields.put("IgnorePayment", "ATM#CVS#BarcodeATM"); fields.put("EncryptType", "1"); fields.put("InvoiceMark", "N"); fields.put("NeedExtraPaidInfo", "Y"); fields.put("CheckMacValue", signer.sign(fields, hashKey, hashIv)); ``` ### Orchestration ```java @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 ```java @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** ```java @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: ```java 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** ```powershell $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: ```xml 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}) ``` 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** ```powershell 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` plus HashKey/HashIV. - Produces: `String sign(Map, String, String)`. - [ ] **Step 1: Write official-vector failing test** Use the exact official appendix parameters and public sample key/IV, then assert: ```java assertEquals("AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266", signer.sign(fields, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS")); ``` - [ ] **Step 2: Write completeness and validation failing tests** ```java 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** ```powershell mvn -pl ruoyi-admin -am -Dtest='OmgCheckMacSignerTest' -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: new signer class missing. - [ ] **Step 4: Implement the minimal signer** ```java public String sign(Map fields, String hashKey, String hashIv) { requireSecrets(hashKey, hashIv); TreeMap 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: ```java 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** ```powershell 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 `