Преглед на файлове

docs: plan OMG payment creation rebuild

qmj преди 2 седмици
родител
ревизия
d48dd90b28

+ 94 - 0
specs/020-omg-payment-rebuild/contracts/api.md

@@ -0,0 +1,94 @@
+# API Contract: 创建 OMG AIO 支付订单
+
+## POST `/pay/omg/create`
+
+### Authentication
+
+- Header: `token: <user-login-token>`
+- Spring annotations: `@Anonymous` + project `@Auth`
+- Controller method must declare `@RequestHeader String token`.
+
+### Request
+
+```http
+POST /pay/omg/create
+Content-Type: application/json
+token: <login-token>
+
+{
+  "orderId": "991786433092835"
+}
+```
+
+Rules:
+
+- DTO has exactly one field: `orderId`.
+- Controller uses explicit `@RequestBody(required = false)`.
+- `orderId` is trimmed, non-empty and at most 64 characters.
+- Client cannot provide amount, MerchantID, gateway URL, ReturnURL, text, channel or signature fields.
+
+### Success
+
+Outer response keeps the project `AjaxResult` format. `data` is:
+
+```json
+{
+  "status": "CREATED",
+  "gatewayUrl": "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5",
+  "formFields": {
+    "MerchantID": "1000031",
+    "MerchantTradeNo": "OMGR8K3P7W2M9C4X6A1B",
+    "MerchantTradeDate": "2026/08/13 15:30:23",
+    "PaymentType": "aio",
+    "TotalAmount": "100",
+    "TradeDesc": "Foodie order 991786433092835",
+    "ItemName": "Order 991786433092835",
+    "ReturnURL": "https://foodieapi.waimai-paotui.com/pay/omg/notify",
+    "ChoosePayment": "ALL",
+    "EncryptType": "1",
+    "InvoiceMark": "N",
+    "NeedExtraPaidInfo": "Y",
+    "ExpireDate": "1",
+    "StoreExpireDate": "30",
+    "BarcodeATMExpireDate": "1",
+    "CheckMacValue": "<64 uppercase hexadecimal characters>"
+  }
+}
+```
+
+The client must submit every `formFields` entry in the current page as an `application/x-www-form-urlencoded` POST to `gatewayUrl`. It must not use iframe, a new window or a GET link.
+
+### Business failure
+
+```json
+{
+  "code": 500,
+  "msg": "<localized message>",
+  "data": {
+    "status": "PAYMENT_ATTEMPT_EXISTS"
+  }
+}
+```
+
+Stable statuses:
+
+| Status | Meaning |
+|---|---|
+| `AUTH_REQUIRED` | No usable user identity reached the new Controller |
+| `ORDER_REQUIRED` | Request or orderId is missing/invalid |
+| `ORDER_NOT_AVAILABLE` | Order does not exist or is not owned by this user |
+| `MULTI_STORE_ORDER_NOT_SUPPORTED` | Matched row is a multi-store child order |
+| `ORDER_STATE_NOT_PAYABLE` | State is not 0, 1 or 2 |
+| `ORDER_ALREADY_PAID` | `pay_status` is not 0 |
+| `ORDER_AMOUNT_INVALID` | Amount is null or not positive |
+| `PAYMENT_TYPE_INVALID` | `pay_type` is not `"2"` |
+| `STORE_CREDENTIAL_UNAVAILABLE` | No enabled credential exists for the order store |
+| `PAYMENT_ATTEMPT_EXISTS` | The order already has an active `CREATED` attempt |
+| `PAYMENT_CONFIGURATION_INVALID` | Stage/ReturnURL safety validation failed |
+| `PAYMENT_CREATION_FAILED` | Controlled generation/insert attempts could not complete |
+
+No error response includes token, HashKey, HashIV, CheckMacValue, form fields, SQL or stack trace.
+
+## Reserved callback route
+
+`POST /pay/omg/notify` remains the ReturnURL value sent to OMG, but this phase intentionally registers no handler. The old handler must not receive this route. Callback implementation requires a later independent specification.

+ 101 - 0
specs/020-omg-payment-rebuild/data-model.md

@@ -0,0 +1,101 @@
+# Data Model: OMG AIO 创建支付尝试
+
+## `pos_order_omg_attempt`
+
+首阶段采用追加式支付尝试表。只允许创建 `CREATED`,不在本阶段定义付款、失败、过期或退款状态。
+
+| Column | Type | Null | Meaning |
+|---|---|---:|---|
+| `id` | `BIGINT` | No | 自增主键 |
+| `dd_id` | `VARCHAR(64)` | No | `pos_order.dd_id` |
+| `merchant_trade_no` | `VARCHAR(20)` ASCII binary | No | OMG MerchantTradeNo,全局唯一 |
+| `store_id` | `BIGINT` | No | 创建时订单门店快照 |
+| `merchant_id` | `VARCHAR(10)` ASCII binary | No | 创建时门店 MerchantID 快照 |
+| `amount` | `INT` | No | 创建时整数 TWD 金额快照 |
+| `attempt_status` | `TINYINT` | No | `0=CREATED` |
+| `active_dd_id` | `VARCHAR(64)` generated | Yes | status=0 时等于 dd_id,否则 NULL |
+| `create_time` | `DATETIME` | No | 本地创建时间 |
+| `update_time` | `DATETIME` | No | 本地更新时间 |
+
+### DDL to append to `updatesql/sql.md`
+
+## 2026-08-13 OMG AIO 创建支付订单从零重建(020-omg-payment-rebuild)
+
+```sql
+-- 仅保存新实现可确认的本地创建事实;不保存 HashKey、HashIV、CheckMacValue 或完整表单。
+-- 本 SQL 只登记,由开发者手动执行;实现过程禁止直接执行 DDL。
+CREATE TABLE pos_order_omg_attempt (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '本地支付尝试主键',
+  dd_id VARCHAR(64) NOT NULL COMMENT '关联 pos_order.dd_id',
+  merchant_trade_no VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'OMG MerchantTradeNo',
+  store_id BIGINT NOT NULL COMMENT '创建时订单门店快照',
+  merchant_id VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '创建时 OMG MerchantID 快照',
+  amount INT NOT NULL COMMENT '创建时整数 TWD 金额快照',
+  attempt_status TINYINT NOT NULL DEFAULT 0 COMMENT '本地状态:0=CREATED',
+  active_dd_id VARCHAR(64)
+    GENERATED ALWAYS AS (IF(attempt_status = 0, dd_id, NULL)) VIRTUAL
+    COMMENT '单订单单活跃尝试唯一键载体',
+  create_time DATETIME NOT NULL COMMENT '创建时间',
+  update_time DATETIME NOT NULL COMMENT '更新时间',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_omg_attempt_trade_no (merchant_trade_no),
+  UNIQUE KEY uk_omg_attempt_active_dd (active_dd_id),
+  KEY idx_omg_attempt_dd_time (dd_id, create_time, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OMG AIO 新支付创建尝试';
+```
+
+### Invariants
+
+1. `merchant_trade_no` 只含大写 ASCII 英数字且长度不超过 20。
+2. `amount > 0`、`store_id` 和 `merchant_id` 均在插入前校验。
+3. `attempt_status=0` 的行通过生成列唯一索引保证每个 `dd_id` 最多一条。
+4. 表中永远没有门店 HashKey、HashIV、完整 CheckMacValue 或完整表单。
+5. 不执行外键约束:沿用项目现有业务表风格,订单归属在事务锁与服务校验中保证。
+
+## Java types
+
+### `OmgPaymentAttempt`
+
+```java
+public class OmgPaymentAttempt {
+    private Long id;
+    private String ddId;
+    private String merchantTradeNo;
+    private Long storeId;
+    private String merchantId;
+    private Integer amount;
+    private Integer attemptStatus;
+    private String activeDdId;
+    private Date createTime;
+    private Date updateTime;
+}
+```
+
+### `OmgPaymentOrderSnapshot`
+
+只承载锁定订单时创建支付所需字段,不暴露到 Controller:
+
+```java
+public class OmgPaymentOrderSnapshot {
+    private Long id;
+    private String ddId;
+    private String parentDdId;
+    private Long storeId;
+    private Long userId;
+    private Integer amount;
+    private Long state;
+    private Long payStatus;
+    private String payType;
+}
+```
+
+## Mapper operations
+
+```java
+OmgPaymentOrderSnapshot selectOrderForUpdate(String ddId);
+OmgPaymentAttempt selectActiveByDdId(String ddId);
+OmgPaymentAttempt selectByMerchantTradeNo(String merchantTradeNo);
+int insertCreated(OmgPaymentAttempt attempt);
+```
+
+`selectOrderForUpdate` 必须在事务中使用 `SELECT ... FOR UPDATE`。`insertCreated` 必须显式列出插入列并排除生成列 `active_dd_id`。

+ 1069 - 0
specs/020-omg-payment-rebuild/plan.md

@@ -0,0 +1,1069 @@
+# 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; every production behavior must follow Red → Green → Refactor.
+
+**Goal:** 从零实现测试环境 `POST /pay/omg/create`,按门店独立凭证生成官方 AIO 签名表单,以新尝试表阻止重复支付入口,并彻底退役旧 OMG 支付/回调/补单/退款运行时。
+
+**Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 只负责新尝试表、锁定订单快照和 MyBatis 持久化;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 用户解析、业务校验、交易号、官方检查码、表单组装、事务编排、Controller 与安全日志。创建流程不调用 OMG HTTP:事务内 `SELECT pos_order ... FOR UPDATE` 后检查活跃尝试,生成并落库 `CREATED`,提交后由 Controller 返回表单。旧 `pos_store_omg` 凭证查询保持不变,其余旧 OMG 支付代码不得被新流程引用。
+
+**Tech Stack:** Java 21、Spring Boot 3、MyBatis/MyBatis-Plus、MySQL、JUnit 5、Mockito、SLF4J/Logback、Maven Surefire。
+
+## Global Constraints
+
+- OMG AIO 官方技术文件 V1.5.3 是支付协议唯一外部事实来源;不得从旧代码或 `specs/016-omg-payment` 复制行为。
+- 只实现创建支付订单;回调、取号通知、查询、补单、退款、关账和正式环境全部不实现。
+- 新代码只能位于 `com.ruoyi.app.omgpay`、`com.ruoyi.system.omgpay` 及对应资源/测试目录。
+- `ruoyi-admin -> ruoyi-system`;`ruoyi-system` 禁止导入 `com.ruoyi.app.*`。
+- 每个门店独立使用 `pos_store_omg` 中已启用的 `MerchantID / HashKey / HashIV`;不修改可信凭证存储与管理代码。
+- 新创建入口固定 `POST /pay/omg/create`;Controller 使用 `@RequestHeader String token`、显式 `@RequestBody` DTO,禁止 Map 入参和 Bean Validation。
+- 客户端请求只含 `orderId`;金额、门店、网关、ReturnURL、说明、支付方式和签名全部由服务端产生。
+- `ChoosePayment=ALL`、`NeedExtraPaidInfo=Y`;所有实际发送的非 `CheckMacValue` 字段全部参加检查码计算。
+- 后续回调必须对全部实际返回字段验签,包含额外字段与空值字段;本阶段只保留规格约束,不注册回调处理器。
+- 网关固定 `https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5`,不得配置正式环境回退。
+- 同一 `ddId` 最多一条 `CREATED`;存在时返回 `PAYMENT_ATTEMPT_EXISTS`,不重放旧表单、不生成新 MerchantTradeNo。
+- DDL 只追加到 `updatesql/sql.md`,绝不连接数据库执行。
+- 所有业务错误使用 `MessageUtils.message(...)`;新增 key 同步 default、`zh_CN`、`zh_TW`、`en_US`、`vi` 五个 properties 文件。
+- 日志不得包含 token、HashKey、HashIV、完整 CheckMacValue、完整表单、DTO 或凭证对象;非预期异常必须以 ERROR 记录异常对象和堆栈。
+- 新公开类型、检查码编码、事务行锁与唯一约束必须有必要注释;禁止显然代码噪声注释。
+- JDK 21 只在当前命令环境设置 `JAVA_HOME`/`PATH`;不修改全局配置。
+- 工作区已有未提交修改。执行前逐文件读取现有 diff;不 reset、不覆盖、不提交与本规格无关的修改。
+
+---
+
+## Summary
+
+### Create transaction
+
+```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.
+
+## 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] TDD,定向测试后再实现。
+- [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("ChoosePayment", "ALL");
+fields.put("EncryptType", "1");
+fields.put("InvoiceMark", "N");
+fields.put("NeedExtraPaidInfo", "Y");
+fields.put("ExpireDate", "1");
+fields.put("StoreExpireDate", "30");
+fields.put("BarcodeATMExpireDate", "1");
+fields.put("CheckMacValue", signer.sign(fields, hashKey, hashIv));
+```
+
+### Orchestration
+
+```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|OrderResultURL|ClientRedirectURL|ClientBackURL" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay
+rg -n "com\.ruoyi\.app\.pay\.OmgPayController|com\.ruoyi\.app\.utils\.omg|pos_order_omg_payment|pos_order_omg_refund" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay ruoyi-system/src/main/java/com/ruoyi/system/omgpay
+rg -n "HashKey|HashIV|CheckMacValue|formFields|token" ruoyi-admin/src/main/java/com/ruoyi/app/omgpay
+```
+
+Interpret results manually: stage URL and required form names are expected; production URL, prohibited fields, old imports and logging of values are not.
+
+- [ ] **Step 4: Check comments and log statements**
+
+Review each new public type and non-obvious signer/transaction/unique-key branch. Confirm necessary “why” comments exist and trivial assignment comments do not. Inspect every `log.*` argument and verify no object serialization or sensitive values.
+
+- [ ] **Step 5: Check diff, encoding and line endings**
+
+```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
+
+1. Official signature vector equals the published SHA-256 CheckMacValue.
+2. All 15 actual pre-sign fields, including extra/expiry fields, are signer inputs; empty fields are retained generically.
+3. Success form has exactly 16 fields and the exact stage URL.
+4. Order is locked before active lookup and generator call.
+5. Repeat and normal concurrent endpoint requests produce no second MerchantTradeNo.
+6. New table unique keys exist in documented DDL; DDL was not executed.
+7. No new code references old OMG Controller, old signer/tools or old payment/refund services.
+8. No reachable main-source SQL references old payment/refund tables.
+9. `pos_store_omg` and credential management remain unchanged.
+10. Old `/pay/omg/notify/query/paymentInfo/return/refund` routes and scheduled task are absent.
+11. Logs have required safe context and unexpected exception stack, with zero secret/full-form leakage.
+12. JDK 21 tests and module build exit 0.
+
+## Spec Coverage Self-Review
+
+| Spec requirements | Implemented/verified by |
+|---|---|
+| FR-001–FR-003 | Tasks 1, 4 and package-isolation scans |
+| FR-004–FR-005 | Task 5 Controller reflection/DTO tests |
+| FR-006–FR-007 | Task 4 validation matrix and server-side amount assertions |
+| FR-008–FR-009 | Task 3 stage constant and trade-number tests |
+| FR-010–FR-011 | Tasks 1 and 4 row lock, active unique key and duplicate tests |
+| FR-012–FR-016 | Task 3 exact field-set, text, timezone and ReturnURL tests |
+| FR-017–FR-019 | Task 2 official vector, all-field mutation and empty-value tests |
+| FR-020–FR-021 | Tasks 3 and 5 response contract; `quickstart.md` client POST acceptance |
+| FR-022 | Task 1 DDL/Mapper contract tests |
+| FR-023–FR-024 | Task 5 log/error tests and five-bundle key parity |
+| FR-025–FR-027 | Task 6 class absence, route absence and main-source zero-match scans |
+| FR-028 | Task 1 SQL-only DDL registration |
+| FR-029 | Tasks 3, 6 and 7 stage-only/no-callback gates |
+| FR-030–FR-031 | Tasks 5 and 7 comment/log reviews and automated log assertions |
+
+Self-review result: all 31 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.
+
+## Risks and Controls
+
+| Risk | Control |
+|---|---|
+| Duplicate payable entries | `FOR UPDATE` on order before generator + `UNIQUE(active_dd_id)` |
+| MerchantTradeNo collision | `UNIQUE(merchant_trade_no)` + bounded three-attempt regeneration |
+| Old table deletion breaks active paths | remove Controller/task/admin/cancel/lifecycle references; main-source zero-match scan |
+| Credentials leak | response whitelist, no credential DTO logging, safe log tests |
+| Signature omits new fields | signer accepts full actual map; completeness mutation tests |
+| Production accidentally enabled | gateway constant is exact stage URL; no production property |
+| Old callback handles new notification | old Controller deleted; route absence test |
+| User dirty work overwritten | pre-edit diff capture, surgical patching, staged-file audit |
+
+## Complexity Tracking
+
+No constitution violations. The separate form factory, signer, generator and persistence service each hold one security-sensitive responsibility and are directly unit testable; none is a generic multi-provider abstraction.

+ 106 - 0
specs/020-omg-payment-rebuild/quickstart.md

@@ -0,0 +1,106 @@
+# Quickstart: OMG AIO 创建支付订单验证
+
+## Prerequisites
+
+1. 使用 JDK 21:`C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`。
+2. 开发者已手动执行 `updatesql/sql.md` 中 `pos_order_omg_attempt` DDL。
+3. 测试门店在可信 `pos_store_omg` 中录入并启用 OMG stage 凭证。
+4. 准备该用户自己的单门店订单:`dd_id = parent_dd_id`、`pay_type='2'`、`pay_status=0`、`state IN (0,1,2)`、`amount>0`。
+5. `omgpay.return-url` 是受控 HTTPS 地址,路径精确为 `/pay/omg/notify`。
+
+## Automated verification
+
+```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' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -Dtest='OmgCheckMacSignerTest,OmgPaymentFormFactoryTest,OmgPaymentCreateServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+## Create request
+
+```powershell
+$headers = @{ token = '<USER_TOKEN>' }
+$body = @{ orderId = '<SINGLE_STORE_DD_ID>' } | ConvertTo-Json
+$response = Invoke-RestMethod -Method Post `
+  -Uri 'https://<API_HOST>/pay/omg/create' `
+  -Headers $headers -ContentType 'application/json' -Body $body
+$response.data
+```
+
+Expected:
+
+- `status=CREATED`。
+- `gatewayUrl` 精确等于 stage AioCheckOut V5 地址。
+- `formFields` 包含规格规定的 16 个字段。
+- `CheckMacValue` 是 64 位大写十六进制。
+- 响应不含 HashKey 或 HashIV。
+
+## Current-page form POST
+
+在客户端当前页面创建 `<form method="post">`,action 设置为 `gatewayUrl`,逐一把 `formFields` 的键和值创建为 hidden input,然后调用 `form.submit()`。不要设置 iframe target,也不要 `window.open()`。
+
+Expected: 浏览器进入 OMG stage 收银台,显示正确金额与门店可用的 `ALL` 渠道。
+
+## Duplicate request
+
+对同一 `orderId` 再调用创建接口。
+
+Expected:
+
+- 返回 `PAYMENT_ATTEMPT_EXISTS`。
+- 没有 `formFields`。
+- 数据库没有第二条该 `dd_id` 的 `CREATED` 行。
+- 不生成或返回新的 MerchantTradeNo。
+
+## Concurrent request
+
+用两个 PowerShell 作业同时提交同一订单:
+
+```powershell
+$uri = 'https://<API_HOST>/pay/omg/create'
+$tokenValue = '<USER_TOKEN>'
+$json = (@{ orderId = '<SINGLE_STORE_DD_ID>' } | ConvertTo-Json -Compress)
+$jobs = 1..2 | ForEach-Object {
+  Start-Job -ScriptBlock {
+    param($u, $t, $b)
+    Invoke-RestMethod -Method Post -Uri $u -Headers @{ token = $t } `
+      -ContentType 'application/json' -Body $b
+  } -ArgumentList $uri, $tokenValue, $json
+}
+$jobs | Wait-Job | Receive-Job
+$jobs | Remove-Job
+```
+
+Expected: 一个请求成功,一个请求返回 `PAYMENT_ATTEMPT_EXISTS`。手动查询:
+
+```sql
+SELECT dd_id, COUNT(*) AS active_count
+FROM pos_order_omg_attempt
+WHERE active_dd_id = '<SINGLE_STORE_DD_ID>'
+GROUP BY dd_id;
+```
+
+`active_count` 必须为 1。
+
+## Negative cases
+
+分别验证无效 token、他人订单、多门店子订单、state=3/4、pay_status 非 0、amount<=0、pay_type 非 2、无启用凭证。每项都必须:
+
+- 返回国际化业务错误与稳定 status;
+- 不写新尝试;
+- 不泄露其他订单或门店凭证详情。
+
+## Log review
+
+确认日志可按 orderId 定位创建开始、校验、成功或拒绝,异常路径含服务端堆栈。全文搜索不得出现:
+
+- 请求 token;
+- HashKey / HashIV 的值;
+- 完整 CheckMacValue;
+- 完整 `formFields` 或凭证对象序列化内容。
+
+## Callback expectation
+
+本阶段 `/pay/omg/notify` 没有处理器。即使在 stage 完成付款,也不得期待订单核销或回调成功;不得把旧 OMG 回调结果作为验收证据。

+ 132 - 0
specs/020-omg-payment-rebuild/research.md

@@ -0,0 +1,132 @@
+# Research: OMG AIO 创建支付订单重建
+
+**Date**: 2026-08-13
+**Decision source**: OMG AIO 官方技术文件 V1.5.3 与已批准的 [spec.md](spec.md)
+
+## 1. 官方文档覆盖
+
+本次已完整检查 OMG AIO 文档首页、介接流程、测试设置、7 个 API 页面与 8 个参考页面,共 18 页。创建支付协议只采用官方页面,不以现有 OMG 代码或 `specs/016-omg-payment` 解释协议。
+
+结论:
+
+- 创建订单使用 `POST application/x-www-form-urlencoded` 到 `https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5`。
+- `ChoosePayment` 是单值字段,不能传 `Credit,ApplePay` 之类组合;本期固定 `ALL`。
+- `MerchantTradeNo` 必须唯一、不可重复使用、最多 20 个 ASCII 英数字。
+- `PaymentType=aio`、`EncryptType=1`、`InvoiceMark=N`、`NeedExtraPaidInfo=Y`。
+- ATM、CVS、BarcodeATM 的期限字段分别为 `ExpireDate=1`、`StoreExpireDate=30`、`BarcodeATMExpireDate=1`。
+- 创建请求中除 `CheckMacValue` 自身外,实际发送的每个字段都参加检查码计算。
+- 后续回调验签也必须包含 OMG 实际返回的全部字段;开启额外信息后,额外字段及空值字段同样不能被过滤。本阶段不实现回调。
+- 查询 API 的 `TimeStamp` 三分钟有效期只是查询请求的防重放规则,不是创建表单或支付尝试的有效期,不能据此自动换新交易号。
+
+## 2. 检查码算法
+
+决定实现新的 `com.ruoyi.app.omgpay.OmgCheckMacSigner`,不引用旧 OMG 签名器。
+
+算法顺序:
+
+1. 拒绝空字段名、`null` 字段值和调用方预先传入的 `CheckMacValue`。
+2. 保留值为 `""` 的字段,不做过滤。
+3. 按字段名自然字母顺序排序。
+4. 以 `key=value&key=value` 串接。
+5. 前置 `HashKey=<key>&`,后置 `&HashIV=<iv>`。
+6. 按 UTF-8 URL encode,并执行官方 .NET 转换表要求的字符还原。
+7. 整串转小写,计算 SHA-256,输出大写十六进制。
+
+自动化测试使用官方附录向量:期望检查码为 `AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266`。官方公开范例键值只用于测试向量,不使用任何门店真实凭证。
+
+## 3. 支付方式
+
+**Decision**: `ChoosePayment=ALL`。
+
+**Rationale**: 官方只允许单一枚举值;`ALL` 会按该门店在 OMG 后台实际开通的能力展示渠道。官方 `IgnorePayment` 没有可靠覆盖 Apple Pay 与所有未来渠道,因此本期不使用它尝试拼出 Credit + ApplePay。
+
+## 4. 门店凭证
+
+**Decision**: 复用现有 `IPosStoreOmgService#getEnabledCredential(Long storeId)` 与 `pos_store_omg`。
+
+**Rationale**: 用户明确确认这部分只负责每个门店独立记录和读取 `MerchantID / HashKey / HashIV`,属于可信实现。新创建流程只读取,不修改凭证表、录入、验证或启停逻辑。
+
+**Rejected**:
+
+- 平台统一凭证:违反每店独立凭证要求。
+- 新建第二张凭证表:重复可信数据源,增加轮换与归属歧义。
+- 从客户端接收 MerchantID:会造成越权使用其他门店凭证。
+
+## 5. 订单定位与单门店判定
+
+当前真实下单代码对单门店订单使用 `PosOrder.ddId == PosOrder.parentDdId`;多门店父单的每个子订单使用带三位序号的 `ddId`,而 `parentDdId` 保持原父单号。
+
+**Decision**: 创建请求的 `orderId` 必须精确命中一条 `pos_order.dd_id`,且满足:
+
+- `parent_dd_id = dd_id`;
+- `md_id` 非空;
+- 登录用户等于 `user_id`。
+
+这会拒绝父单没有对应 `pos_order` 行的请求,也会拒绝多门店子订单。金额只取该行 `amount`。
+
+## 6. 并发与重复创建
+
+**Decision**: 使用数据库行锁与新表唯一键,不复用旧 OMG 的三分钟窗口或旧支付流水服务。
+
+事务顺序:
+
+1. `SELECT` 目标 `pos_order` 行并 `FOR UPDATE`。
+2. 校验归属、单门店、订单状态、`payType="2"`、`payStatus=0`、金额和门店。
+3. 查询 `pos_order_omg_attempt.active_dd_id = ddId`。
+4. 已存在则返回 `PAYMENT_ATTEMPT_EXISTS`,不生成新交易号、不重放旧表单。
+5. 读取启用门店凭证并验证服务端配置。
+6. 生成表单和检查码。
+7. 插入 `CREATED` 尝试并提交事务。
+
+同一订单的并发请求会阻塞在订单行锁上;后到请求在前一事务提交后读取到活跃尝试,因此不会生成第二个交易号。`UNIQUE(active_dd_id)` 是其他写入路径绕过行锁时的最终防线,`UNIQUE(merchant_trade_no)` 防止全局交易号碰撞。
+
+**Rejected**:
+
+- 固定时间后自动创建新尝试:没有可信网关状态,可能产生多个可付款入口。
+- 重放旧表单:旧 `MerchantTradeDate` 与重复提交语义没有官方保证。
+- 只依赖进程内锁:无法覆盖多实例部署。
+- 只先查后插:并发下会产生双写竞争和第二个交易号。
+
+## 7. 新表与状态
+
+新表 `pos_order_omg_attempt` 只记录本地可确认事实:业务订单、交易号、门店、MerchantID 快照、金额、状态和时间。
+
+首阶段唯一状态为 `0=CREATED`。它只表示本地表单事实已生成并持久化,不能解释为 OMG 已收单、已付款或未付款。表内不保存 HashKey、HashIV、CheckMacValue 或完整表单。
+
+## 8. 配置边界
+
+- 网关地址在新代码中固定为 stage 完整端点,不提供正式地址回退。
+- 新配置前缀为 `omgpay`,只配置受控 HTTPS `return-url`。
+- `return-url` 必须无 user-info、query 和 fragment,路径必须精确为 `/pay/omg/notify`。
+- 第一阶段不注册 `/pay/omg/notify` 处理器;测试通知预期不会改变订单状态。
+
+## 9. API 与错误
+
+`POST /pay/omg/create` 使用 `@RequestHeader String token` 和显式 `@RequestBody OmgCreatePaymentRequest`,DTO 只含 `orderId`。
+
+成功返回 `AjaxResult.success(data)`,其中 data 为:
+
+```json
+{
+  "status": "CREATED",
+  "gatewayUrl": "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5",
+  "formFields": {}
+}
+```
+
+业务失败返回 `AjaxResult.error(message, {"status":"<stable-code>"})`。订单不存在和越权使用同一外部状态 `ORDER_NOT_AVAILABLE`,避免泄露订单存在性;重复尝试使用 `PAYMENT_ATTEMPT_EXISTS`。
+
+## 10. 注释与日志
+
+- 公开类型说明职责和安全边界。
+- 检查码编码、`FOR UPDATE`、生成列唯一键和重复键恢复说明原因。
+- 不注释显然 getter、setter、赋值或 Spring 样板。
+- 创建开始记录 orderId/userId;校验通过记录 storeId;成功提交后记录 attemptId、orderId、userId、storeId、amount、状态和脱敏交易号。
+- 业务拒绝记录稳定错误码与已有安全上下文;非预期异常以 ERROR 记录异常对象和堆栈。
+- 永不记录 token、HashKey、HashIV、完整 CheckMacValue、完整表单、完整 DTO 或凭证对象。
+
+## 11. 旧实现退役
+
+旧 `OmgPayController`、旧回调 DTO、旧退款 DTO、旧补单任务、旧支付/退款实体与 Mapper/Service 不再保留运行时入口。订单取消和管理端移除旧 OMG 退款/补单调用,`OrderLifecycleService` 移除旧表依赖。`pos_store_omg` 与其凭证管理代码保持不变。
+
+这项删除只负责阻止旧代码在旧表删除后被调用,不为回调、退款、补单或状态核销提供替代实现;这些能力必须在后续独立规格中重新设计。

+ 2 - 2
specs/020-omg-payment-rebuild/spec.md

@@ -4,7 +4,7 @@
 
 **Created**: 2026-08-13
 
-**Status**: Draft — awaiting written specification review
+**Status**: Approved for planning and implementation
 
 **Input**: 以 OMG 全方位金流 AIO 官方技术文件 V1.5.3(2026-07)为唯一外部事实来源,从零重建创建支付订单;现有 OMG 支付代码、旧支付流水和 `specs/016-omg-payment` 均不作为需求或设计依据。第一阶段只完成测试环境首次创建并进入 OMG 收银台,其余能力后续逐项重建。
 
@@ -170,7 +170,7 @@ token: <login-token>
   "gatewayUrl": "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5",
   "formFields": {
     "MerchantID": "1000031",
-    "MerchantTradeNo": "OMGR8K3P7W2M9C4X6A1",
+    "MerchantTradeNo": "OMGR8K3P7W2M9C4X6A1B",
     "MerchantTradeDate": "2026/08/13 15:30:23",
     "PaymentType": "aio",
     "TotalAmount": "100",

+ 100 - 0
specs/020-omg-payment-rebuild/tasks.md

@@ -0,0 +1,100 @@
+# Tasks: OMG AIO 创建支付订单重建
+
+**Input**: [spec.md](spec.md), [plan.md](plan.md), [research.md](research.md), [data-model.md](data-model.md), [contracts/api.md](contracts/api.md), [quickstart.md](quickstart.md)
+
+**Tests**: 本功能强制 TDD。每个生产任务先写失败测试并实际观察失败,再写最小实现。
+
+**Scope**: 只完成测试环境创建支付订单;不实现回调、查询、补单、退款、关账或正式环境。
+
+## Phase 1: 新持久化基础
+
+- [ ] T001 [US1] 在 `ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java` 编写新尝试字段校验、`CREATED` 插入和生成列不由 Java 写入的失败测试
+- [ ] T002 [US2] 在 `ruoyi-system/src/test/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapperContractTest.java` 编写 `FOR UPDATE`、新表名、唯一键及无旧表 SQL 的失败合约测试
+- [ ] T003 [US1] 在 `updatesql/sql.md` 仅追加 `pos_order_omg_attempt` DDL,不执行数据库变更
+- [ ] T004 [US1] 在 `ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/` 新建 `OmgPaymentAttempt` 和 `OmgPaymentOrderSnapshot`
+- [ ] T005 [US1] 在 `ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java` 与 `ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml` 实现锁定订单、活跃尝试、交易号查询和显式 INSERT
+- [ ] T006 [US1] 在 `ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/` 实现 `IOmgPaymentAttemptService` 与校验严格的 `OmgPaymentAttemptServiceImpl`
+- [ ] T007 [US1] 运行 `OmgPaymentAttemptServiceTest,OmgPaymentAttemptMapperContractTest` 并确认通过
+
+**Checkpoint**: 新表 DDL 已登记,system 模块可锁定订单并只写新尝试表。
+
+## Phase 2: 官方检查码与表单
+
+- [ ] T008 [US1] 在 `OmgCheckMacSignerTest` 写入官方期望值 `AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266`、额外字段、空值字段、输入不可变和非法字段失败测试
+- [ ] T009 [US1] 在 `com.ruoyi.app.omgpay.OmgCheckMacSigner` 实现排序、HashKey/HashIV 包夹、官方 .NET URL encode、小写、SHA-256 和大写输出
+- [ ] T010 [US1] 运行 `OmgCheckMacSignerTest`,确认官方向量和字段完整性全部通过
+- [ ] T011 [US1] 在 `OmgMerchantTradeNoGeneratorTest` 编写 20 位大写英数字与样本唯一性失败测试
+- [ ] T012 [US1] 在 `OmgPaymentFormFactoryTest` 编写 Taipei 时间、stage URL、16 字段、固定字段、全部字段签名及不安全 ReturnURL 失败测试
+- [ ] T013 [US1] 实现 `OmgPaymentProperties`、`OmgMerchantTradeNoGenerator`、`OmgPaymentForm` 与 `OmgPaymentFormFactory`
+- [ ] T014 [US1] 在 `application.yml` 新增独立 `omgpay.return-url`,新代码不读取旧 `omg.*` 创建/回调配置
+- [ ] T015 [US1] 运行生成器和表单测试并确认通过
+
+**Checkpoint**: 可从服务端事实生成一份严格 stage-only、全字段签名的表单。
+
+## Phase 3: 创建编排与重复防护
+
+- [ ] T016 [US3] 在 `OmgPaymentCreateServiceTest` 编写订单不存在/越权、非单店、非法状态、已支付、金额、payType、门店和凭证失败矩阵
+- [ ] T017 [US2] 在同一测试编写已有活跃尝试时返回 `PAYMENT_ATTEMPT_EXISTS` 且不调用生成器/表单/凭证的失败测试
+- [ ] T018 [US1] 在同一测试编写成功调用顺序、响应白名单和安全内部元数据失败测试
+- [ ] T019 [US2] 在同一测试编写 active 唯一键冲突、MerchantTradeNo 冲突三次受控重试和未知重复键失败测试
+- [ ] T020 [US1] 实现 `OmgPaymentErrorCode`、`OmgPaymentBusinessException`、`OmgPaymentCreateOutcome` 和响应 DTO
+- [ ] T021 [US1] 实现 `@Transactional OmgPaymentCreateService#create`,保证锁订单和活跃查询发生在 MerchantTradeNo 生成前
+- [ ] T022 [US2] 实现重复键分类:发现活跃行立即终止,仅交易号碰撞允许最多三次重新生成
+- [ ] T023 [US1] 运行 `OmgPaymentCreateServiceTest` 并检查新包对旧 OMG 类/Service 零引用
+
+**Checkpoint**: 首次请求创建,重复请求稳定拒绝;没有任何网关 HTTP 或旧支付表访问。
+
+## Phase 4: 公共 API、i18n 与排障日志
+
+- [ ] T024 [US3] 在 `OmgPaymentControllerTest` 编写 `/pay/omg/create`、token header、显式 DTO、无 Map 入参和请求字段白名单失败测试
+- [ ] T025 [US3] 编写 Controller 业务错误、成功、未知异常的响应契约失败测试
+- [ ] T026 [US3] 编写 INFO/WARN/ERROR、成功安全上下文、异常堆栈及敏感值不入日志的失败测试
+- [ ] T027 [US3] 在 default、zh_CN、zh_TW、en_US、vi 五个 i18n 文件加入完全一致的 `omg.pay.*` key 集合
+- [ ] T028 [US3] 实现 `OmgPaymentTokenUserResolver`、请求/错误 DTO 与新 `OmgPaymentController`
+- [ ] T029 [US3] 实现创建开始、成功、业务拒绝和非预期异常日志,不记录 token、凭证、完整检查码或表单
+- [ ] T030 [US1] 运行 `OmgPaymentControllerTest` 和 i18n key 对齐检查
+
+**Checkpoint**: 新 Controller 独占 `/pay/omg/create`,响应与日志满足安全契约。
+
+## Phase 5: 旧 OMG 运行时退役
+
+- [ ] T031 [US3] 保存当前脏文件定向 diff,确认需要保留的 LINE/订单状态改动
+- [ ] T032 [US3] 在 `OmgLegacyRetirementTest` 编写旧 Controller、旧任务、旧支付实体必须不存在及新 Controller 只有 create 路由的失败测试
+- [ ] T033 [US3] 删除旧 `OmgPayController`、专属 DTO、`OmgReconcileTask`、`OmgQueryThrottle` 和旧 Controller 测试
+- [ ] T034 [US3] 删除旧支付/退款 Entity、Mapper、XML、Service、ServiceImpl 和对应旧测试;保留 `pos_store_omg` 全部可信实现
+- [ ] T035 [US3] 从 `UserOrderController` 与 `PosOrderShOprateController` 取消链路移除旧 OMG 退款调用,不改变其他取消行为
+- [ ] T036 [US3] 从 `PosOrderController` 移除旧 OMG 补单、退款、人工确认端点及注入/辅助方法
+- [ ] T037 [US3] 从 `OrderLifecycleService` 移除旧表依赖和旧 OMG 补单/退款方法,保留 `payType="2"` 的支付前进门禁
+- [ ] T038 [US3] 定点更新 `OrderLifecycleServiceTest`,移除旧表测试/构造参数并保留已有 LINE 和订单生命周期测试
+- [ ] T039 [US3] 运行 `OmgLegacyRetirementTest,OrderLifecycleServiceTest`,并确认 main Java/XML 对旧支付/退款表及服务零匹配
+- [ ] T040 [US3] 将 `application.yml` 的旧 `omg` 配置收敛为凭证验证所需 `base-url`,移除旧回调/查询/补单/退款配置并保留新 `omgpay.return-url`
+- [ ] T041 [US3] 复核三个原脏文件的无关改动仍在,且没有全文件换行或格式化
+
+**Checkpoint**: 旧表删除后没有可达运行链路访问它们;旧回调、查询、补单和退款接口全部不可达。
+
+## Phase 6: 综合验证与手动验收准备
+
+- [ ] T042 运行全部新 OMG 测试以及 `OrderLifecycleServiceTest,LinePayCancellationRaceTest`
+- [ ] T043 使用 JDK 21 运行 `mvn -pl ruoyi-admin -am -DskipTests package`
+- [ ] T044 检查禁止的 production URL、旧 OMG import、旧表 SQL、敏感日志与实际表单字段集合
+- [ ] T045 审查新公开类型、检查码、事务行锁和唯一键处理的必要注释,移除显然代码噪声注释
+- [ ] T046 运行 `git diff --check`、`git status --short`、`git diff --stat` 并确认无无关文件
+- [ ] T047 开发者手动执行 DDL 后,按 `quickstart.md` 验证首次创建、当前页 POST、重复、真实并发、负例和日志
+
+## Dependencies & Execution Order
+
+- Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6。
+- T008 与 T011/T012 可独立写测试,但实施时顺序执行以维持清晰 TDD 证据。
+- T031 必须早于任何脏文件修改;T041 必须在旧代码退役完成后执行。
+- 手动 stage 验收依赖开发者执行 DDL,自动测试和构建不依赖数据库变更。
+
+## Completion Definition
+
+- `POST /pay/omg/create` 首次返回可提交的 stage 表单。
+- 同订单重复/并发创建最多一条 `CREATED`,后续请求为 `PAYMENT_ATTEMPT_EXISTS`。
+- 官方检查码向量一致,实际发送字段无遗漏。
+- 新代码全在 `omgpay` 包,不引用旧 OMG 支付实现。
+- 旧 Controller、回调、查询、补单、退款、任务及旧表运行时引用为零。
+- `pos_store_omg` 凭证实现未修改。
+- 日志足以定位问题且无敏感信息;非预期异常保留堆栈。
+- JDK 21 定向测试与构建通过,最终 diff 无无关改动。