# 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` 复制行为。
- 实现创建、最终付款结果回调、用户触发查询补偿，以及测试环境退款安全拒绝；取号通知、定时/批量补单、正式退款动作、通用支付推送、关账和正式环境全部不实现。外送订单支付成功后的骑手开放推送按 2026-08-28 增量实现。
- 新代码只能位于 `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<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:

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

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

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

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

```powershell
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`:

```java
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:

```java
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:

```yaml
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:

```powershell
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**

```java
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**

```powershell
mvn -pl ruoyi-admin -am -Dtest='OmgPaymentCreateServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
```

- [ ] **Step 5: Implement the transactional service**

```java
@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:

```java
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:

```java
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**

```powershell
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**

```powershell
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:

```java
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**

```powershell
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:

```properties
# 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:

```java
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**

```powershell
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**

```powershell
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**

```java
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**

```powershell
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**

```powershell
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:

```powershell
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**

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

```powershell
mvn -pl ruoyi-admin -am -DskipTests package
```

Expected: `BUILD SUCCESS` on JDK 21.

- [ ] **Step 3: Run static safety checks**

```powershell
rg -n "payment\.funpoint\.com\.tw|PlatformID|PaymentInfoURL|ClientRedirectURL|ClientBackURL" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay
rg -n "OrderResultURL|ChoosePayment|IgnorePayment|UnionPay" 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、`OrderResultURL`、`ChoosePayment=ALL` 与 `IgnorePayment=ATM#CVS#BarcodeATM` 是预期结果；production URL、`UnionPay`、其他禁用字段、旧 import 和敏感值日志不是预期结果。

- [ ] **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**

```powershell
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](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:

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

Do not create an empty commit.

## Verification Gates

- 查询测试源码覆盖当前尝试选择、请求签名、额外/空字段响应验签、未付款只读、失败同步、成功补偿和已付款不可逆。
- 本阶段遵守用户要求不运行 Maven、编译或测试，待所有 OMG 功能调整完成后统一验证。

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. New `/pay/omg/notify` is the only notify route; old query/paymentInfo/return/refund routes and scheduled task remain absent.
11. Every callback is appended to existing `ipn_log`; full callback logging contains no database HashKey/HashIV.
12. Callback updates only payment facts and `pos_order.pay_status`; no order/delivery/push/refund side effects.
13. Maven tests and build are intentionally deferred until all OMG functions are adjusted.

## 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 create vector; T052/T056 callback actual-field, empty and unknown-field tests |
| FR-020–FR-021 | Tasks 3 and 5 response contract; `quickstart.md` client POST acceptance |
| FR-022 | Task 1 creation DDL plus T048–T051 credential snapshot extension |
| FR-023–FR-024 | Task 5 create log/error tests; T054/T058/T059 full callback/IPN logs |
| 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 T060 stage-only/new callback gates |
| FR-030–FR-031 | Tasks 5 and 7 comment/log reviews and automated log assertions |
| FR-032–FR-035 | T052–T057 DTO boundary, IPN transaction, snapshot signature and invariant validation |
| FR-036–FR-041 | T049/T056–T059 success-priority state machine, order-only payment update and gateway facts |
| FR-042–FR-044 | T071–T076 test-stage refund boundary, i18n and safe logging |
| FR-045–FR-050 | T082–T087 verified retry orchestration, exact-attempt replacement, App contract and regression tests |
| FR-051–FR-055 | T093–T098 sparse query response validation, fail-closed identity checks and retry recovery |
| FR-056–FR-060 | T099–T103 current WebView return listener, exact URL handoff and compatibility verification |

Self-review result: all 60 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 |
| Credential rotation breaks callback | immutable HashKey/HashIV snapshot on each attempt |
| Credentials leak | response whitelist; full callback logs only request data, never DB secrets |
| 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; single new route mapping test |
| Duplicate/late notifications corrupt state | attempt row lock + success-priority conditional updates |
| IPN failure loses payment | REQUIRES_NEW audit is best effort and never gates notify processing |
| 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.

### Query compensation transaction

1. Controller 只接收 token 与 `orderId`，验证订单归属。
2. 未付款订单选择唯一 `CREATED`；已付款订单选择首条 `PAID`，客户端不能指定交易号。
3. 使用尝试的凭证快照和当前 Unix 秒签署 stage `QueryTradeInfo/V5` 请求。
4. 严格解析响应，全部实际字段参与验签，并核对 MerchantID、MerchantTradeNo 与金额。
5. `TradeStatus=1/10200095` 进入与回调相同的订单/尝试锁和不可逆状态机；`0` 保持只读。
6. 查询不是 IPN，不写 `ipn_log`；日志只记录必要的脱敏定位信息。

### Test-stage refund boundary

1. `POST /pay/omg/refund` 使用 token 和只有 `orderId` 的显式 DTO。
2. Service 只校验身份与订单号格式，随后稳定返回环境不支持错误。
3. 代码中不引入正式 `CreditDetail/DoAction` 地址、HTTP Gateway、退款表或订单/支付更新。
4. Controller 返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT` 及五语言提示；异常统一为 `PAYMENT_REFUND_FAILED`。

## 2026-08-13 旧实现与旧表最终清理

- 删除 `com.ruoyi.app.utils.omg` 下的 `OmgPay`、`OmgPayConfig`、`OmgCheckMacValue` 及对应测试；rebuild 运行代码只使用 `com.ruoyi.app.omgpay` 和 `com.ruoyi.system.omgpay`。
- 保留可信的 `pos_store_omg` Entity、Mapper、Service 和管理接口。原 Controller 的凭证联网探测迁入 `OmgStoreCredentialVerifier`，复用新的 `OmgCheckMacSigner`、`OmgQueryResponseParser` 与 stage-only `OmgPaymentQueryGateway`。
- 删除旧 `omg.base-url` 配置；stage 查询地址继续由 rebuild 网关固定控制，不接受客户端或旧配置覆盖。
- 仅在 `updatesql/sql.md` 追加按依赖顺序删除 `pos_order_omg_refund`、`pos_order_omg_payment` 的 SQL，不在实现会话中执行；保留 `pos_store_omg`、`pos_order_omg_attempt` 和 `ipn_log`。
- 保留 `specs/016-omg-payment` 与历史建表 SQL 作为审计记录，但它们不再定义当前实现。

## 2026-08-14 已销毁 WebView 的重新支付

1. App 首次付款仍调用 `/pay/omg/create`；退出支付页后不保存、不恢复原 WebView，也不重放旧表单。
2. 用户明确再次付款时调用 `/pay/omg/retry`。retry 先通过可信 `/query` 链路向 OMG 核实旧交易；客户端只能重新选择受控 `paymentMethod`，不能指定交易号或 OMG 原始支付参数。
3. `PAID` 直接拒绝新建；`FAILED` 在旧尝试已释放后正常创建；`UNPAID` 仅允许 `paymentType` 为空的尝试被替换；其他状态 fail closed。
4. `UNPAID` 替换在独立写事务中锁订单并再次验证订单，要求活动尝试交易号与 query 结果精确一致，然后以 `id + CREATED` 条件更新为 `SUPERSEDED` 并插入新尝试。
5. 两个并发 retry 即使都查到同一旧交易，也只有先取得订单锁者能替换；后取得者看到活动交易号变化后返回 `PAYMENT_RETRY_NOT_AVAILABLE`。
6. retry 成功复用 create 响应，返回新 `MerchantTradeNo` 的新表单。无需数据库结构变更，现有 `attempt_status=3` 与生成列唯一键已覆盖该流程。

## 2026-08-17 OMG 官方支付方式选择页实施计划

**Goal:** 保持 App 直接进入 OMG 官方支付方式选择页，通过服务端隐藏 ATM、CVS 和 BarcodeATM，同时由 OMG 商户侧关闭 AFTEE，最终保留信用卡与 Apple Pay。

**Architecture:** 不修改 `POST /pay/omg/create`、`POST /pay/omg/retry` 的请求或响应结构。两条路径继续复用 `OmgPaymentFormFactory#create(...)`；工厂固定发送 `ChoosePayment=ALL` 与 `IgnorePayment=ATM#CVS#BarcodeATM`，不发送 `UnionPay`，并把实际过滤字段纳入 `CheckMacValue`。不通过前端 CSS、DOM 注入或未公开参数修改 OMG 第三方页面。

**Tech Stack:** Java 21、Spring Boot、JUnit 5、Mockito、Maven、OMG AIO stage。

### Task 1: 表单渠道参数与签名

**Files:**

- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java`

**Interfaces:**

- Consumes: `OmgPaymentFormFactory#create(String orderId, Integer amount, String merchantId, String hashKey, String hashIv, String merchantTradeNo)`
- Produces: 原接口不变；`OmgPaymentForm.fields()` 返回包含 `ChoosePayment=ALL`、`IgnorePayment=ATM#CVS#BarcodeATM` 且不含 `UnionPay` 的不可变字段集合。

- [x] 将测试改为 `buildsSignedFormThatUsesOmgPageForCreditAndApplePay`：字段集合移除 `UnionPay`、加入 `IgnorePayment`；断言 `ChoosePayment=ALL`、`IgnorePayment=ATM#CVS#BarcodeATM`、无 `UnionPay`；签名入参仍为 14 个非 `CheckMacValue` 字段。
- [ ] 按项目 OMG 延后验证约束，等待统一验证阶段使用 JDK 21 运行 `OmgPaymentFormFactoryTest`，确认旧 `ChoosePayment=Credit` 实现会使新断言失败。
- [x] 修改 `OmgPaymentFormFactory`，改为以下固定字段：

```java
fields.put("ChoosePayment", "ALL");
fields.put("IgnorePayment", "ATM#CVS#BarcodeATM");
```

- [ ] 在统一验证阶段再次运行 `OmgPaymentFormFactoryTest`，确认字段集合、值和签名入参全部通过。

### Task 2: App 文档、回归与提交

**Files:**

- Modify: `specs/020-omg-payment-rebuild/omg-app-integration.md`
- Modify: `specs/020-omg-payment-rebuild/tasks.md`

**Interfaces:**

- Consumes: create/retry 现有 `{status, gatewayUrl, formFields}` 响应。
- Produces: App 仍直接 POST 全部 `formFields`，不增加支付渠道参数、二级选择页面或原生 Apple Pay SDK。

- [x] 更新规格、契约、快速验收和 App 文档中的示例表单及字段说明：`ChoosePayment=ALL`、`IgnorePayment=ATM#CVS#BarcodeATM`，删除 `UnionPay`；注明 AFTEE 必须由 OMG 商户侧关闭，Apple Pay 取决于门店开通状态和设备环境。
- [ ] 在统一验证阶段使用 JDK 21 运行全部 OMG 定向测试：

```powershell
$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
mvn -pl ruoyi-admin -am "-Dtest=com.ruoyi.app.omgpay.*Test,com.ruoyi.system.omgpay.*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test
```

- [ ] 在统一验证阶段运行模块构建：

```powershell
mvn -pl ruoyi-admin -am -DskipTests package
```

- [x] 静态检查源码中只存在预期渠道参数，`git diff --check` 无错误；暂存范围在提交前单独核对，不包含 `.claude/homunculus/*`。
- [ ] 部署到 OMG stage 后人工确认信用卡可用、Apple Pay 在适用环境可用、ATM/CVS/BarcodeATM 不显示，并确认 OMG 商户侧已关闭 AFTEE。
- [ ] 使用中文提交信息提交并推送当前分支：`修复：恢复 OMG 官方支付方式选择页`。

## 2026-08-18 App 双入口严格支付渠道设计

> 本节取代“2026-08-17 OMG 官方支付方式选择页实施计划”作为当前渠道实现依据。旧节保留用于说明已提交实现的历史背景，不再定义目标行为。

**Goal:** 测试环境没有商户后台渠道开关时，仍严格保证用户只能发起信用卡或 Apple Pay，不显示超商快付与 AFTEE。

**Architecture:** App 在进入 OMG 前显示“信用卡”和“Apple Pay”两个选项，并向 create/retry 传递稳定枚举 `paymentMethod`。后端只接受 `CREDIT/APPLE_PAY`，分别生成 `ChoosePayment=Credit + UnionPay=2` 或 `ChoosePayment=ApplePay` 的签名表单，不再使用 `ALL` 或 `IgnorePayment`。客户端不得传递 `ChoosePayment`、`UnionPay` 等 OMG 原始参数。支付尝试表、回调、查询、结果页和测试环境退款边界保持不变，无数据库变更。

### 后端边界

- `OmgCreatePaymentRequest` 与 `OmgRetryPaymentRequest` 只增加 `paymentMethod`；Controller 先校验白名单，再把领域枚举传给 Service。
- create 与 retry 共用同一渠道映射和表单工厂，避免两条路径生成不同渠道参数。
- `CREDIT`：发送并签名 `ChoosePayment=Credit`、`UnionPay=2`，直接进入信用卡流程并隐藏银联。
- `APPLE_PAY`：发送并签名 `ChoosePayment=ApplePay`，不发送 `UnionPay` 或 `IgnorePayment`。
- 缺失、空白、大小写不匹配或其他值统一返回 `PAYMENT_METHOD_INVALID`；校验失败不得创建、替换或更新支付尝试。

### App 交互与契约

- 用户选择 OMG 后，在 App 自有页面选择“信用卡”或“Apple Pay”；Apple Pay 的可用性仍受 OMG 门店开通状态和设备环境约束。
- 首次支付调用 create，重新支付调用 retry；两者都传递本次用户选择的 `paymentMethod`。retry 可以重新选择渠道，但仍先执行现有可信查询和替换规则。
- create/retry 成功后继续在当前完整 WebView 原样 Form POST 全部 `formFields`，不接入原生 Apple Pay SDK，不通过 CSS 或 DOM 修改 OMG 页面。
- 当前工作区未包含用户端 App 源码；后端仓库完成契约、服务端实现和交接文档后，App UI 与请求改动需在用户端仓库实施和验收。

### 测试与验收

- DTO/Controller：覆盖 create/retry 的两个合法枚举及缺失、空白、未知值；非法值断言无 Service 调用或尝试变更。
- 表单工厂：分别断言信用卡与 Apple Pay 字段集合、禁止字段及完整签名输入。
- Service：断言 create/retry 将选择值贯穿到新尝试表单，原有并发、查询和不可逆支付规则不变。
- OMG stage：分别验证信用卡和 Apple Pay 直接流程；页面不得出现超商快付、AFTEE 或其他渠道选择项。
- 按项目 OMG 延后验证约束，测试源码与生产代码在同一实施批次完成，Maven、构建和 stage 验收等待全部 OMG 计划功能调整完成后统一执行。

### 可执行范围与文件职责

本计划的可执行批次只覆盖当前 `foodie_server` 仓库。已在 `E:\QtwCode` 查找用户端源码；候选仓库 `CTE/cte_share` 与 `CTE/food-pdy` 都没有规格指定的 `pages/OrderList/buy/omgCheckout.vue`，也没有当前 OMG create/retry 对接代码，因此不得猜测其中任一仓库就是生产 App。App 修改必须在用户确认实际仓库后单独形成精确计划。

服务端批次不修改 Entity、Mapper、数据库表或 `updatesql/sql.md`。文件职责如下：

- Create: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentMethod.java`：定义 App 白名单枚举、严格解析规则与 OMG 单渠道映射。
- Create: `ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentMethodTest.java`：锁定大小写、空白和未知值拒绝规则。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgCreatePaymentRequest.java`：只增加 `paymentMethod` 字段及访问器。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgRetryPaymentRequest.java`：只增加 `paymentMethod` 字段及访问器。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java`：增加稳定错误 `PAYMENT_METHOD_INVALID`。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java`：token 验证后解析白名单枚举，并传递给 create/retry Service。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java`：让首次创建和精确替换都携带已验证枚举。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRetryService.java`：在查询或写入前拒绝空枚举，并把重新选择的渠道传给新表单。
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java`：按枚举生成互斥的信用卡或 Apple Pay 字段并签名。
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java`、`OmgPaymentControllerTest.java`、`OmgPaymentRetryControllerTest.java`、`OmgPaymentCreateServiceTest.java`、`OmgPaymentRetryServiceTest.java`：覆盖 DTO 契约、无副作用拒绝、参数贯穿和签名字段集合。
- Modify: `ruoyi-admin/src/main/resources/i18n/messages.properties`、`messages_zh_CN.properties`、`messages_zh_TW.properties`、`messages_en_US.properties`、`messages_vi.properties`：提供同一错误 key 的五套文案。
- Modify: `specs/020-omg-payment-rebuild/tasks.md`：实现提交后只更新 T105/T106，统一验证和 App 任务保持未完成。

### Task 18: create/retry 支付渠道白名单服务端批次

**Interfaces:**

- Consumes: create/retry JSON `{orderId, paymentMethod}`，其中 `paymentMethod` 只能是精确字符串 `CREDIT` 或 `APPLE_PAY`。
- Produces: `OmgPaymentMethod.require(String)`；`OmgPaymentCreateService#create(Long, String, OmgPaymentMethod)`；`OmgPaymentCreateService#replaceActiveForRetry(Long, String, String, OmgPaymentMethod)`；`OmgPaymentRetryService#retry(Long, String, OmgPaymentMethod)`；`OmgPaymentFormFactory#create(String, Integer, String, String, String, String, OmgPaymentMethod)`。
- Security invariant: 客户端不能提交 `ChoosePayment`、`UnionPay`、`IgnorePayment` 或其他 OMG 原始字段；非法 `paymentMethod` 在 query、订单锁、尝试创建或替换前失败。

- [ ] **Step 1: 先写枚举、表单和 Controller 失败测试源码，但按 OMG 延后验证规则暂不运行**

`OmgPaymentMethodTest` 必须包含以下精确断言：

```java
assertEquals(OmgPaymentMethod.CREDIT, OmgPaymentMethod.require("CREDIT"));
assertEquals(OmgPaymentMethod.APPLE_PAY, OmgPaymentMethod.require("APPLE_PAY"));
for (String invalid : new String[]{"", "credit", "Credit", "ALL", " CREDIT "}) {
    OmgPaymentBusinessException error = assertThrows(
            OmgPaymentBusinessException.class,
            () -> OmgPaymentMethod.require(invalid));
    assertEquals(OmgPaymentErrorCode.PAYMENT_METHOD_INVALID, error.getCode());
}
assertEquals(OmgPaymentErrorCode.PAYMENT_METHOD_INVALID,
        assertThrows(OmgPaymentBusinessException.class,
                () -> OmgPaymentMethod.require(null)).getCode());
```

`OmgPaymentFormFactoryTest` 将旧的 `ALL + IgnorePayment` 用例拆成两个用例：

```java
OmgPaymentForm credit = factory.create("DD-1", 100, "1000031", "KEY", "IV",
        "OMG12345678901234567", OmgPaymentMethod.CREDIT);
assertEquals("Credit", credit.fields().get("ChoosePayment"));
assertEquals("2", credit.fields().get("UnionPay"));
assertFalse(credit.fields().containsKey("IgnorePayment"));

OmgPaymentForm applePay = factory.create("DD-1", 100, "1000031", "KEY", "IV",
        "OMG12345678901234567", OmgPaymentMethod.APPLE_PAY);
assertEquals("ApplePay", applePay.fields().get("ChoosePayment"));
assertFalse(applePay.fields().containsKey("UnionPay"));
assertFalse(applePay.fields().containsKey("IgnorePayment"));
```

同时捕获 signer 入参：信用卡签名前字段数为 14，包含 `ChoosePayment=Credit`、`UnionPay=2`；Apple Pay 签名前字段数为 13，包含 `ChoosePayment=ApplePay` 且不含 `UnionPay/IgnorePayment/CheckMacValue`。

Controller 测试把 DTO 字段集合断言改为：

```java
assertEquals(Set.of("orderId", "paymentMethod"),
        Arrays.stream(OmgCreatePaymentRequest.class.getDeclaredFields())
                .map(Field::getName).collect(Collectors.toSet()));
```

create/retry 成功用例分别设置 `CREDIT`、`APPLE_PAY` 并验证 Service 收到领域枚举；缺失、`ALL`、`Credit` 和带空白值返回 `PAYMENT_METHOD_INVALID`，且 `verifyNoInteractions(createService/retryService)`。

- [ ] **Step 2: 写 Service 参数贯穿与无副作用测试源码，但暂不运行**

将现有 create Service 测试调用统一增加 `OmgPaymentMethod.CREDIT`，并验证工厂参数：

```java
verify(formFactory).create(eq("DD-1"), eq(100), eq("1000031"), eq("KEY"), eq("IV"),
        anyString(), eq(OmgPaymentMethod.CREDIT));
```

新增 null 防御用例，要求在任何依赖交互前失败：

```java
OmgPaymentBusinessException error = assertThrows(OmgPaymentBusinessException.class,
        () -> service.create(5L, "DD-1", null));
assertEquals(PAYMENT_METHOD_INVALID, error.getCode());
verifyNoInteractions(attempts, credentials, generator, formFactory);
```

retry Service 的 `FAILED` 与可替换 `UNPAID` 用例分别验证：

```java
verify(createService).create(5L, "DD-1", OmgPaymentMethod.APPLE_PAY);
verify(createService).replaceActiveForRetry(5L, "DD-1", "OMGOLD",
        OmgPaymentMethod.CREDIT);
```

retry 的 null 防御必须在 `queryService.query(...)` 前返回 `PAYMENT_METHOD_INVALID`，并断言 `verifyNoInteractions(queryService, createService)`。原有 `PAID/UNKNOWN/paymentType 非空/并发变化` 断言保持不变，只补充枚举参数。

- [ ] **Step 3: 实现严格枚举、DTO 字段、稳定错误和五语言文案**

`OmgPaymentMethod.java` 使用以下完整领域边界：

```java
package com.ruoyi.app.omgpay;

/** Maps the App payment-method allowlist to one OMG hosted payment channel. */
public enum OmgPaymentMethod {
    CREDIT("Credit", true),
    APPLE_PAY("ApplePay", false);

    private final String choosePayment;
    private final boolean unionPayDisabled;

    OmgPaymentMethod(String choosePayment, boolean unionPayDisabled) {
        this.choosePayment = choosePayment;
        this.unionPayDisabled = unionPayDisabled;
    }

    public String getChoosePayment() {
        return choosePayment;
    }

    public boolean isUnionPayDisabled() {
        return unionPayDisabled;
    }

    public static OmgPaymentMethod require(String value) {
        try {
            return value == null ? invalid() : valueOf(value);
        } catch (IllegalArgumentException error) {
            return invalid();
        }
    }

    private static OmgPaymentMethod invalid() {
        throw new OmgPaymentBusinessException(OmgPaymentErrorCode.PAYMENT_METHOD_INVALID);
    }
}
```

两个请求 DTO 都只增加：

```java
private String paymentMethod;

public String getPaymentMethod() {
    return paymentMethod;
}

public void setPaymentMethod(String paymentMethod) {
    this.paymentMethod = paymentMethod;
}
```

错误枚举增加 `PAYMENT_METHOD_INVALID("omg.pay.payment.method.invalid")`。五个 properties 文件使用同一 key：

```properties
# messages.properties / messages_zh_CN.properties
omg.pay.payment.method.invalid=请选择信用卡或 Apple Pay
# messages_zh_TW.properties
omg.pay.payment.method.invalid=請選擇信用卡或 Apple Pay
# messages_en_US.properties
omg.pay.payment.method.invalid=Please select Credit Card or Apple Pay
# messages_vi.properties
omg.pay.payment.method.invalid=Vui lòng chọn Thẻ tín dụng hoặc Apple Pay
```

- [ ] **Step 4: 让 Controller、create/retry Service 和表单工厂只传递领域枚举**

Controller 在 token 解析成功后执行白名单解析，再调用 Service：

```java
String paymentMethodValue = request == null ? null : request.getPaymentMethod();
safeUserId = tokenUserResolver.requireUserId(token);
OmgPaymentMethod paymentMethod = OmgPaymentMethod.require(paymentMethodValue);
OmgPaymentCreateOutcome outcome = createService.create(safeUserId, orderId, paymentMethod);
```

retry 同样调用 `retryService.retry(safeUserId, orderId, paymentMethod)`。不得把原始 `paymentMethodValue` 写入日志或传入表单工厂。

create Service 在 `create(...)` 和 `replaceActiveForRetry(...)` 的第一条业务判断调用统一的 `requirePaymentMethod(paymentMethod)`，然后把枚举传入 `createWithBoundedTradeNumberRetries(...)`：

```java
private static void requirePaymentMethod(OmgPaymentMethod paymentMethod) {
    if (paymentMethod == null) {
        throw business(PAYMENT_METHOD_INVALID);
    }
}
```

retry Service 在调用 query 前执行同样的 null 判断并抛出 `new OmgPaymentBusinessException(PAYMENT_METHOD_INVALID)`，再在 `FAILED`、可替换 `UNPAID` 两条路径传递原枚举。

表单工厂签名追加 `OmgPaymentMethod paymentMethod`，删除旧固定字段并改为：

```java
fields.put("ChoosePayment", paymentMethod.getChoosePayment());
if (paymentMethod.isUnionPayDisabled()) {
    fields.put("UnionPay", "2");
}
```

`IgnorePayment` 和 `ChoosePayment=ALL` 不得出现在生产代码。签名器继续接收表单实际字段的不可变副本，现有字段安全清洗、URL 校验和响应不可变性保持不变。

- [ ] **Step 5: 做一次统一静态审查，不运行 Maven、编译或测试**

```powershell
rg -n 'ChoosePayment|IgnorePayment|UnionPay|paymentMethod|PAYMENT_METHOD_INVALID' `
  ruoyi-admin/src/main/java/com/ruoyi/app/omgpay `
  ruoyi-admin/src/test/java/com/ruoyi/app/omgpay `
  ruoyi-admin/src/main/resources/i18n
rg -n 'omg.pay.payment.method.invalid' `
  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
git diff --check
```

人工确认：生产代码没有 `ChoosePayment=ALL` 或 `IgnorePayment`；只有信用卡分支包含 `UnionPay=2`；客户端原始字符串只在 Controller 白名单解析边界出现；日志、错误响应和测试失败信息不包含 token、HashKey、HashIV、CheckMacValue 或完整表单；无 Entity、Mapper、SQL 或数据库文件变化。

- [ ] **Step 6: 更新任务状态、核对暂存范围并形成一个中文实现提交**

仅在生产代码、测试源码、五语言 i18n 和文档静态审查完成后，把 T105/T106 标记为完成；T107/T108/T109 保持未完成。使用 PowerShell 数组暂存本批次明确文件，运行 `git diff --cached --name-only` 和 `git diff --cached --check`，确认不包含 `.claude/homunculus/*` 或其他用户脏文件，然后提交：

```powershell
git commit -m "实现 OMG 信用卡与 Apple Pay 双入口"
```

提交后状态只能报告为“已提交”，不能报告“已验证”。

### Task 19: 全部 OMG 功能完成后的统一验证

**Interfaces:**

- Consumes: Task 18 的独立实现提交与项目现有 OMG 全部功能。
- Produces: 定向测试、完整 OMG 回归、模块构建和 stage 双渠道人工证据。

- [ ] **Step 1: 使用 JDK 21 运行渠道定向测试**

```powershell
$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
mvn -pl ruoyi-admin -am "-Dtest=com.ruoyi.app.omgpay.OmgPaymentMethodTest,com.ruoyi.app.omgpay.OmgPaymentFormFactoryTest,com.ruoyi.app.omgpay.OmgPaymentControllerTest,com.ruoyi.app.omgpay.OmgPaymentRetryControllerTest,com.ruoyi.app.omgpay.OmgPaymentCreateServiceTest,com.ruoyi.app.omgpay.OmgPaymentRetryServiceTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```

Expected: Maven 退出码 0，六个指定测试类全部通过；信用卡签名前 14 个字段，Apple Pay 签名前 13 个字段。

- [ ] **Step 2: 运行 OMG 回归与模块构建**

```powershell
mvn -pl ruoyi-admin -am "-Dtest=com.ruoyi.app.omgpay.*Test,com.ruoyi.system.omgpay.*Test" "-Dsurefire.failIfNoSpecifiedTests=false" test
mvn -pl ruoyi-admin -am -DskipTests package
```

Expected: 两条命令退出码均为 0，构建输出 `BUILD SUCCESS`。

- [ ] **Step 3: 执行 OMG stage 双渠道验收**

按 [quickstart.md](quickstart.md) 分别发送 `paymentMethod=CREDIT` 与 `paymentMethod=APPLE_PAY`。确认信用卡表单只含 `ChoosePayment=Credit + UnionPay=2`，Apple Pay 表单只含 `ChoosePayment=ApplePay`；两个页面均不出现超商快付、AFTEE 或其他渠道选择。Apple Pay 若因门店未开通或设备不支持而失败，记录为外部环境限制，不把信用卡成功当作 Apple Pay 验收证据。

- [ ] **Step 4: 更新 T108/T109 并提交验证记录**

只有上述自动化、构建和人工证据全部检查后，才能把功能状态报告为“已验证”。若 App 仍未接入，服务端最多报告为“已验证”，完整用户功能仍不得宣称完成。

## 2026-08-14 retry 查询稀疏响应修复计划

**Goal:** 修复用户连续点击支付时，retry 查询到已签名但省略非核心字段的 OMG 响应而错误返回 `PAYMENT_QUERY_FAILED`。

**Architecture:** 保持 `/pay/omg/query` 与 `/pay/omg/retry` 接口不变。查询服务对所有实际字段验签，并将核心身份字段与结算字段分层校验：任何状态都必须核对商户号、交易号、金额、状态和签名；只有 `PAID` 必须具备完整结算字段，`UNPAID/10200095/10200047` 可省略非核心字段。OMG stage 对从未提交的表单返回签名有效的 `TradeStatus=10200047` 与 `TradeAmt=0`，该组合按“网关不存在交易”同步失败并进入既有 retry 新建表单流程；其他状态继续要求金额与本地尝试一致。

**Tech Stack:** Java 21、Spring Boot、JUnit 5、Mockito、Maven、OMG AIO stage。

### Task 1: 稀疏响应回归测试

**Files:**

- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java`

**Interfaces:**

- Consumes: `OmgPaymentQueryService#query(Long userId, String orderId)`。
- Produces: 对已签名的稀疏 `TradeStatus=0/10200095` 响应分别返回 `UNPAID/FAILED`；对 stage 实际返回的 `TradeStatus=10200047 + TradeAmt=0` 返回 `FAILED`；缺少核心字段、签名错误、`10200047` 金额非零或已付款结算字段缺失时仍返回 `PAYMENT_QUERY_FAILED`。

- [x] 先增加稀疏未付款/失败响应测试，并运行 `OmgPaymentQueryServiceTest` 观察旧实现因固定 17 字段校验而失败。
- [x] 增加缺少核心金额、已付款缺少结算字段的 fail-closed 回归断言。
- [x] 增加 stage 实际 `10200047 + TradeAmt=0` 响应与非零金额篡改的回归断言，并观察现有金额校验导致前者失败。

### Task 2: 状态感知校验与验证

**Files:**

- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java`
- Modify: `specs/020-omg-payment-rebuild/spec.md`
- Modify: `specs/020-omg-payment-rebuild/tasks.md`

**Interfaces:**

- Consumes: `OmgQueryResponseParser#parse(String)` 返回的完整实际字段集合。
- Produces: 核心字段校验、完整实际字段验签、按 `TradeStatus` 校验结算字段，以及仅包含字段名称的安全诊断日志。

- [x] 将固定字段存在性校验改为核心字段校验；验签仍覆盖解析得到的全部实际字段。
- [x] 仅对 `TradeStatus=1` 强制要求支付交易号、支付方式、付款时间、建单时间和通路费；失败状态允许这些字段不存在。
- [x] 仅在签名和身份验证通过且 `TradeStatus=10200047 + TradeAmt=0` 时同步旧尝试失败，使 retry 复用既有 `FAILED -> create` 流程。
- [x] 使用 JDK 21 运行查询定向测试、全部 OMG 回归与 `ruoyi-admin` 模块构建。
- [x] 执行 `git diff --check`、最终差异和暂存范围检查，不包含 `.claude/homunculus/*`。

## 2026-08-17 iOS WebView 返回交接实施计划

**Goal:** 让 OMG 专用页面 `pages/OrderList/buy/omgCheckout` 在 iOS 中可靠识别后端结果页已经加载，并把控制权交给 App 逻辑层；后端保留现有结果 HTML、“返回 App”按钮和 Scheme 兜底。

**Architecture:** `omgCheckout` 的 renderjs 继续只负责提交 OMG HTML Form。普通 `<script>` 在 App-Plus iOS 环境获取当前页面 `this.$scope.$getAppWebview()`，监听原生 `loaded` 并通过 `getURL()` 精确识别 `/pay/omg/result`；识别后只触发一次前端业务处理入口。该页面没有 `<web-view>` 子组件，因此不得读取 `children()[0]`。

**Tech Stack:** uni-app App-vue（Vue2）、renderjs、HTML5+ `WebviewObject`、iOS WKWebView、Java 21/Spring Boot 后端兼容回归。

### Global Constraints

- 实际前端文件位于用户端 App 仓库 `pages/OrderList/buy/omgCheckout.vue`；该仓库不在当前后端工作区，本计划不假设或修改其请求封装。
- 当前页面是 OMG 专用页面，不增加 `payChannel` 参数。
- 监听对象必须是当前页面 `WebviewObject`，不得使用 `children()[0]`。
- 只在 App-Plus iOS 启用新监听；Android 与外部浏览器继续保留现有返回页兜底行为。
- 必须精确匹配 `https://foodieapi.waimai-paotui.com/pay/omg/result` 及其 query/hash 形式。
- 结果页到达只产生一次 `OMG_RETURNED` 信号，不代表支付成功；查询、轮询、提示和最终路由由前端业务实现。
- `/pay/omg/result` 继续返回现有 HTML；本阶段不删除提示内容、“返回 App”按钮、Bridge 诊断或 App Scheme。
- 日志只记录 URL 是否匹配、尝试次数和脱敏订单号，不记录完整 URL、token、表单字段或 `CheckMacValue`。

### File Structure

```text
用户端 App 仓库（当前工作区外）
└── pages/OrderList/buy/omgCheckout.vue  # 绑定当前页面 WebView、识别结果 URL、清理监听

当前后端仓库
├── ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/
│   └── OmgPaymentClientReturnServiceTest.java  # 现有结果 HTML/按钮/Scheme 兼容回归
└── specs/020-omg-payment-rebuild/
    ├── omg-ios-webview-return-fix.md           # 已批准交接设计
    ├── plan.md                                 # 本实施计划
    └── tasks.md                                # T099-T103 执行清单
```

### Task 1: 用户端当前 WebView 返回监听

**Files:**

- Modify: `pages/OrderList/buy/omgCheckout.vue`
- Test: 使用用户端 App 现有 Vue/uni-app 测试目录为该页面增加原生 WebView mock 测试；若该仓库没有自动化测试入口，以 Task 2 的 iPhone 真机日志作为原生交接验收，不在后端仓库创建伪前端测试。

**Interfaces:**

- Consumes: `this.$scope.$getAppWebview(): WebviewObject`、`WebviewObject#addEventListener('loaded', handler)`、`WebviewObject#getURL(): string`。
- Produces: 同一次结果页加载最多调用一次前端业务入口 `handleOmgReturnDetected({ orderId, currentUrl })`；该入口的支付查询和路由实现由前端负责。

- [ ] **Step 1: 先验证当前页面结构与失败基线**

  在 iPhone 真机或 iOS 模拟环境记录当前页面 WebView URL 变化，确认 Form 提交后当前 `WebviewObject` 依次加载 OMG 收银台和 `/pay/omg/result`，并确认页面没有可供监听的 `<web-view>` 子组件。预期旧版本最终停留在后端结果页，控制台没有 `[OMG-APP] result_page_detected`。

- [ ] **Step 2: 在普通 `<script>` 增加固定结果地址与状态**

  在组件外声明：

  ```js
  const OMG_RESULT_URL =
    'https://foodieapi.waimai-paotui.com/pay/omg/result'
  ```

  在 `data()` 中增加 `omgReturnHandled: false`。不得把完整 OMG 表单或返回 URL 保存到响应式数据。

- [ ] **Step 3: 在 App-Plus iOS 生命周期绑定当前页面 WebView**

  将以下行为合并进页面现有生命周期，不覆盖原有 `onReady`/`onUnload`：

  ```js
  onReady() {
    // #ifdef APP-PLUS
    if (uni.getSystemInfoSync().platform === 'ios') {
      this.bindOmgReturnListener()
    }
    // #endif
  },

  onUnload() {
    this.clearOmgReturnListener()
  }
  ```

  `bindOmgReturnListener()` 必须使用当前页面对象：

  ```js
  const pageWebView = this.$scope.$getAppWebview()
  ```

  保存 `pageWebView` 和具名 `loaded` handler，绑定后立即调用一次 URL 检查。不得调用 `pageWebView.children()`。

- [ ] **Step 4: 精确识别结果页并做一次性交接**

  URL 判断必须等价于：

  ```js
  const isResultPage =
    currentUrl === OMG_RESULT_URL ||
    currentUrl.indexOf(OMG_RESULT_URL + '?') === 0 ||
    currentUrl.indexOf(OMG_RESULT_URL + '#') === 0
  ```

  非结果地址直接返回；结果地址且 `omgReturnHandled === false` 时先把它设为 `true`，再记录脱敏日志并调用：

  ```js
  this.handleOmgReturnDetected({
    orderId: this.orderId,
    currentUrl
  })
  ```

  `handleOmgReturnDetected` 是本交接单元提供给前端业务的唯一入口。该入口不得仅凭 URL 直接声明支付成功；查询和最终路由由前端在用户端仓库中完成。

- [ ] **Step 5: 清理监听并验证重复事件**

  `clearOmgReturnListener()` 使用保存的同一个 handler 调用 `removeEventListener('loaded', handler)`，然后清空引用。模拟两次相同 `loaded` 时，`handleOmgReturnDetected` 调用次数必须为 1；页面卸载后调用次数必须保持不变。

- [ ] **Step 6: 使用中文提交用户端改动**

  ```powershell
  git add -- 'pages/OrderList/buy/omgCheckout.vue'
  git commit -m '修复：接管 OMG iOS WebView 返回信号'
  ```

### Task 2: 后端兼容回归与真机验收

**Files:**

- Verify only: `ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPageRenderer.java`
- Verify only: `ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnServiceTest.java`
- Reference: `specs/020-omg-payment-rebuild/omg-ios-webview-return-fix.md`

**Interfaces:**

- Consumes: `POST /pay/omg/result` 返回的现有 `text/html` 页面。
- Produces: 保持 HTTP 200、繁体提示、“返回 App”按钮、App Scheme 和无敏感信息的现有兼容页面；iOS 主流程不依赖这些兜底成功。

- [ ] **Step 1: 运行现有后端结果页定向测试**

  ```powershell
  $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
  $env:Path="$env:JAVA_HOME\bin;$env:Path"
  mvn -pl ruoyi-admin -am "-Dtest=OmgPaymentClientReturnServiceTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
  ```

  预期退出码为 0；`verifiedPaymentResultReturnsNoStoreHtmlThatOnlyNavigatesToApp` 和 `verifiedPaymentResultUsesUniAppBridgeBeforeKeepingSchemeFallback` 继续通过。除非测试暴露与已批准规格直接冲突，不修改返回页生产代码。

- [ ] **Step 2: 执行 iPhone 真机主流程验收**

  完成一笔 OMG stage 支付，确认日志顺序至少包含：

  ```text
  [OMG-APP] webview_loaded {"isOmgResultPage":true}
  [OMG-APP] result_page_detected {"orderRef":"***7389"}
  ```

  同一次返回不得出现第二次 `result_page_detected`。即使结果页仍输出旧 Bridge/Scheme 失败日志，也不得阻止 App 逻辑层收到该信号。

- [ ] **Step 3: 执行负例和兼容验收**

  在 OMG 收银台中间页、取消页和其他 URL 上确认不触发 `result_page_detected`；使用 Android 和外部浏览器确认现有结果页提示、按钮与 Scheme 仍保留。

- [ ] **Step 4: 检查交付范围**

  后端仓库不应出现 `OmgPaymentReturnPageRenderer.java` 生产差异；用户端提交只包含 `omgCheckout.vue` 及该仓库确有的对应测试。不得混入 `.claude/homunculus/*`。

### Coverage Self-Review

| 设计要求 | 实施/验证位置 |
|---|---|
| 监听当前页面而非子 WebView | Task 1 Steps 1、3 |
| 精确识别固定结果 URL | Task 1 Step 4 |
| 只触发一次并在卸载时清理 | Task 1 Step 5 |
| 结果页到达不等于支付成功 | Task 1 Step 4、Task 2 Step 2 |
| 保留现有 HTML、按钮与 Scheme | Task 2 Steps 1、3 |
| iOS 主流程不依赖旧 Bridge/Scheme | Task 2 Step 2 |
| Android/外部浏览器不受影响 | Task 2 Step 3 |

自检结论：交接设计的每项要求都有明确实施或验收点；前端查询、轮询、提示和最终路由明确属于交接入口的消费方，不在本计划中虚构实现。
