Sfoglia il codice sorgente

docs: specify OMG payment callback rebuild

qmj 2 settimane fa
parent
commit
c5a1104ebe

+ 45 - 2
specs/020-omg-payment-rebuild/contracts/api.md

@@ -89,6 +89,49 @@ Stable statuses:
 
 No error response includes token, HashKey, HashIV, CheckMacValue, form fields, SQL or stack trace.
 
-## Reserved callback route
+## POST `/pay/omg/notify`
 
-`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.
+### Request
+
+```http
+POST /pay/omg/notify
+Content-Type: application/x-www-form-urlencoded
+
+MerchantID=1000031&MerchantTradeNo=OMGR8K3P7W2M9C4X6A1B&RtnCode=1&RtnMsg=Succeeded&TradeNo=26081300000000000001&TradeAmt=100&PaymentDate=2026%2F08%2F13+16%3A20%3A00&PaymentType=Credit_CreditCard&PaymentTypeChargeFee=3&TradeDate=2026%2F08%2F13+16%3A18%3A00&SimulatePaid=1&CustomField1=&CustomField2=&CustomField3=&CustomField4=&card4no=4242&CheckMacValue=<CHECK_MAC_VALUE>
+```
+
+Rules:
+
+- No login token is required.
+- Controller receives one explicit `OmgNotifyRequest` DTO; it does not accept `Map` or `HttpServletRequest`.
+- The request boundary preserves the exact raw body plus every decoded parameter, including unknown fields and empty values.
+- Duplicate parameter names, malformed percent encoding, unsupported content type or an oversized body are invalid.
+- Signature input contains every actual parameter except `CheckMacValue`; no field whitelist is used.
+- Credential lookup begins from the locked `MerchantTradeNo` attempt and uses its credential snapshot.
+- `MerchantID` and `TradeAmt` must equal the creation snapshot.
+
+### Acknowledged
+
+```http
+HTTP/1.1 200 OK
+Content-Type: text/plain;charset=UTF-8
+
+1|OK
+```
+
+This response is used for a verified success, verified final failure, or an idempotently repeated fact. `RtnCode=1` marks the attempt and order paid even when `SimulatePaid=1`. `RtnCode!=1` marks a non-paid attempt failed and leaves the order unpaid.
+
+### Retryable rejection
+
+```http
+HTTP/1.1 200 OK
+Content-Type: text/plain;charset=UTF-8
+
+0|ERROR
+```
+
+This response is used when the request cannot be trusted or atomically processed: unknown trade number, invalid/missing fields, duplicate fields, credential snapshot unavailable, signature mismatch, MerchantID/amount mismatch, conflicting gateway `TradeNo`, or database transaction failure. No order/payment state is changed.
+
+### IPN logging
+
+Every HTTP request is inserted before business handling into existing `ipn_log` with `type=omg`, request IP, receive time and the full raw form body. This insert uses an independent transaction. Failure to write `ipn_log` is logged but does not block payment processing.

+ 60 - 10
specs/020-omg-payment-rebuild/data-model.md

@@ -1,8 +1,8 @@
-# Data Model: OMG AIO 创建支付尝试
+# Data Model: OMG AIO 创建支付尝试与付款结果
 
 ## `pos_order_omg_attempt`
 
-首阶段采用追加式支付尝试表。只允许创建 `CREATED`,不在本阶段定义付款、失败、过期或退款状态
+采用追加式支付尝试表。创建时保存订单、商户和凭证快照;付款结果回调在同一行追加网关最终事实,不覆盖历史尝试
 
 | Column | Type | Null | Meaning |
 |---|---|---:|---|
@@ -12,17 +12,28 @@
 | `store_id` | `BIGINT` | No | 创建时订单门店快照 |
 | `merchant_id` | `VARCHAR(10)` ASCII binary | No | 创建时门店 MerchantID 快照 |
 | `amount` | `INT` | No | 创建时整数 TWD 金额快照 |
-| `attempt_status` | `TINYINT` | No | `0=CREATED` |
+| `hash_key_snapshot` | `VARCHAR(64)` | No | 创建时 HashKey 快照,仅用于该尝试验签 |
+| `hash_iv_snapshot` | `VARCHAR(64)` | No | 创建时 HashIV 快照,仅用于该尝试验签 |
+| `attempt_status` | `TINYINT` | No | `0=CREATED, 1=PAID, 2=FAILED, 3=SUPERSEDED` |
 | `active_dd_id` | `VARCHAR(64)` generated | Yes | status=0 时等于 dd_id,否则 NULL |
+| `trade_no` | `VARCHAR(20)` ASCII binary | Yes | OMG 金流交易编号,非空时全局唯一 |
+| `rtn_code` | `INT` | Yes | OMG 原始交易状态码 |
+| `rtn_msg` | `VARCHAR(200)` | Yes | OMG 原始交易说明 |
+| `payment_type` | `VARCHAR(20)` ASCII | Yes | OMG 回覆付款方式 |
+| `payment_date` | `DATETIME` | Yes | OMG 付款时间 |
+| `trade_date` | `DATETIME` | Yes | OMG 订单成立时间 |
+| `payment_type_charge_fee` | `INT` | Yes | OMG 回传手续费 |
+| `simulate_paid` | `TINYINT` | Yes | `0=一般付款, 1=模拟付款` |
+| `last_notify_time` | `DATETIME` | Yes | 最近一次合法通知处理时间 |
 | `create_time` | `DATETIME` | No | 本地创建时间 |
 | `update_time` | `DATETIME` | No | 本地更新时间 |
 
-### DDL to append to `updatesql/sql.md`
+### DDL registered in `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 '本地支付尝试主键',
@@ -31,14 +42,26 @@ CREATE TABLE pos_order_omg_attempt (
   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',
+  hash_key_snapshot VARCHAR(64) NOT NULL COMMENT '创建时 HashKey 快照',
+  hash_iv_snapshot VARCHAR(64) NOT NULL COMMENT '创建时 HashIV 快照',
+  attempt_status TINYINT NOT NULL DEFAULT 0 COMMENT '本地状态:0=CREATED,1=PAID,2=FAILED,3=SUPERSEDED',
   active_dd_id VARCHAR(64)
     GENERATED ALWAYS AS (IF(attempt_status = 0, dd_id, NULL)) VIRTUAL
     COMMENT '单订单单活跃尝试唯一键载体',
+  trade_no VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT 'OMG 金流交易编号',
+  rtn_code INT DEFAULT NULL COMMENT 'OMG 原始结果码',
+  rtn_msg VARCHAR(200) DEFAULT NULL COMMENT 'OMG 原始结果说明',
+  payment_type VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT 'OMG 回覆付款方式',
+  payment_date DATETIME DEFAULT NULL COMMENT 'OMG 付款时间',
+  trade_date DATETIME DEFAULT NULL COMMENT 'OMG 订单成立时间',
+  payment_type_charge_fee INT DEFAULT NULL COMMENT 'OMG 回传手续费',
+  simulate_paid TINYINT DEFAULT NULL COMMENT '0一般付款,1模拟付款',
+  last_notify_time DATETIME DEFAULT NULL 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_gateway_trade_no (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 新支付创建尝试';
@@ -49,8 +72,9 @@ CREATE TABLE pos_order_omg_attempt (
 1. `merchant_trade_no` 只含大写 ASCII 英数字且长度不超过 20。
 2. `amount > 0`、`store_id` 和 `merchant_id` 均在插入前校验。
 3. `attempt_status=0` 的行通过生成列唯一索引保证每个 `dd_id` 最多一条。
-4. 表中永远没有门店 HashKey、HashIV、完整 CheckMacValue 或完整表单。
-5. 不执行外键约束:沿用项目现有业务表风格,订单归属在事务锁与服务校验中保证。
+4. `PAID` 不可降级;`FAILED` 可被后续合法成功通知升级为 `PAID`。
+5. 密钥快照永远不输出到 API、普通日志或 `ipn_log`;完整回调内容只来自 HTTP 请求本身,不拼入数据库密钥。
+6. 不执行外键约束:沿用项目现有业务表风格,订单归属在事务锁与服务校验中保证。
 
 ## Java types
 
@@ -64,8 +88,19 @@ public class OmgPaymentAttempt {
     private Long storeId;
     private String merchantId;
     private Integer amount;
+    private String hashKeySnapshot;
+    private String hashIvSnapshot;
     private Integer attemptStatus;
     private String activeDdId;
+    private String tradeNo;
+    private Integer rtnCode;
+    private String rtnMsg;
+    private String paymentType;
+    private Date paymentDate;
+    private Date tradeDate;
+    private Integer paymentTypeChargeFee;
+    private Integer simulatePaid;
+    private Date lastNotifyTime;
     private Date createTime;
     private Date updateTime;
 }
@@ -94,8 +129,23 @@ public class OmgPaymentOrderSnapshot {
 ```java
 OmgPaymentOrderSnapshot selectOrderForUpdate(String ddId);
 OmgPaymentAttempt selectActiveByDdId(String ddId);
-OmgPaymentAttempt selectByMerchantTradeNo(String merchantTradeNo);
+OmgPaymentAttempt selectByMerchantTradeNoForUpdate(String merchantTradeNo);
 int insertCreated(OmgPaymentAttempt attempt);
+int markPaid(OmgPaymentAttempt attempt);
+int markFailed(OmgPaymentAttempt attempt);
+int supersedeOtherCreated(String ddId, Long paidAttemptId);
+int markOrderPaid(String ddId);
 ```
 
-`selectOrderForUpdate` 必须在事务中使用 `SELECT ... FOR UPDATE`。`insertCreated` 必须显式列出插入列并排除生成列 `active_dd_id`。
+`selectOrderForUpdate` 与 `selectByMerchantTradeNoForUpdate` 必须在事务中使用 `SELECT ... FOR UPDATE`。`insertCreated` 必须显式列出插入列并排除生成列 `active_dd_id`。状态更新必须带当前状态条件,保证 `PAID` 不可降级。
+
+## Existing `ipn_log`
+
+不新增字段。每个 HTTP 回调独立新增一行:
+
+| Column | Value |
+|---|---|
+| `ip` | 请求来源 IP |
+| `cretim` | 本地接收时间 |
+| `ipn_log` | 完整原始 form-urlencoded 请求体 |
+| `type` | 固定 `omg` |

+ 41 - 19
specs/020-omg-payment-rebuild/plan.md

@@ -1,31 +1,31 @@
-# OMG AIO 创建支付订单重建 Implementation Plan
+# 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 支付/回调/补单/退款运行时
+**Goal:** 从零实现测试环境 `POST /pay/omg/create` 和 `POST /pay/omg/notify`,按门店独立凭证创建官方 AIO 表单,并以凭证快照可信、幂等地同步最终付款结果
 
-**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 支付代码不得被新流程引用。
+**Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 负责新尝试表、订单锁、状态 CAS、凭证快照和 `ipn_log` 独立事务;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 创建入口、原始表单 DTO 边界、官方检查码、回调校验、事务编排与日志。创建流程保存实际密钥快照;回调先独立写 IPN,再按 `MerchantTradeNo` 锁尝试并用快照验签,成功只更新订单 `pay_status`。旧 `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` 字段全部参加检查码计算。
-- 后续回调必须对全部实际返回字段验签,包含额外字段与空值字段;本阶段只保留规格约束,不注册回调处理器
+- 回调必须对全部实际返回字段验签,包含未知额外字段与空值字段,只有 `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 记录异常对象和堆栈
+- 创建日志不得包含 token、HashKey、HashIV、完整 CheckMacValue、完整表单、DTO 或凭证对象;回调按已批准策略直接记录完整 HTTP 回传与 CheckMacValue,但任何日志不得包含数据库中的 HashKey/HashIV
 - 新公开类型、检查码编码、事务行锁与唯一约束必须有必要注释;禁止显然代码噪声注释。
-- JDK 21 只在当前命令环境设置 `JAVA_HOME`/`PATH`;不修改全局配置
+- 当前回调阶段只编写测试源码并做静态审计;Maven、编译和测试留到 OMG 全部功能调整完毕后统一执行
 - 工作区已有未提交修改。执行前逐文件读取现有 diff;不 reset、不覆盖、不提交与本规格无关的修改。
 
 ---
@@ -65,6 +65,22 @@ second request waits on the same pos_order row lock
 
 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
@@ -88,9 +104,9 @@ Business failures throw `OmgPaymentBusinessException` carrying `OmgPaymentErrorC
 - [x] DDL 只写 `updatesql/sql.md`。
 - [x] 支付、输入、凭证、日志通过 security-review 门禁。
 - [x] 新表使用 InnoDB、参数化 MyBatis、生成列唯一键和 `FOR UPDATE`。
-- [x] TDD,定向测试后再实现
+- [x] 测试源码先于生产代码;按用户要求当前阶段不运行 Maven/编译/测试
 - [x] 只保留 `pos_store_omg` 可信能力,旧支付/退款表运行时引用清零。
-- [x] 实现回调、查询、退款或生产环境。
+- [x] 实现最终付款回调;不实现取号、查询、退款、推送或生产环境。
 
 结论:无须复杂性豁免。
 
@@ -1026,9 +1042,10 @@ Do not create an empty commit.
 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.
+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
 
@@ -1040,16 +1057,18 @@ Do not create an empty commit.
 | 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-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 DDL/Mapper contract tests |
-| FR-023–FR-024 | Task 5 log/error tests and five-bundle key parity |
+| 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 7 stage-only/no-callback gates |
+| 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 |
 
-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.
+Self-review result: all 41 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.
 
 ## Risks and Controls
 
@@ -1058,10 +1077,13 @@ Self-review result: all 31 functional requirements have an implementation task a
 | 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 |
+| 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; route absence test |
+| 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

+ 22 - 1
specs/020-omg-payment-rebuild/quickstart.md

@@ -103,4 +103,25 @@ GROUP BY dd_id;
 
 ## Callback expectation
 
-本阶段 `/pay/omg/notify` 没有处理器。即使在 stage 完成付款,也不得期待订单核销或回调成功;不得把旧 OMG 回调结果作为验收证据。
+## 付款结果回调重放
+
+使用真实 OMG stage 回调日志中的完整 form-urlencoded 内容重放;不要自行删除空值字段或额外字段:
+
+```powershell
+$body = '<从 ipn_log.ipn_log 或应用日志复制的完整回调内容>'
+Invoke-WebRequest -Method Post `
+  -Uri 'https://foodieapi.waimai-paotui.com/pay/omg/notify' `
+  -ContentType 'application/x-www-form-urlencoded' `
+  -Body $body
+```
+
+预期:
+
+- 签名、商户、交易号和金额匹配时返回纯文本 `1|OK`。
+- `RtnCode=1`(包括 `SimulatePaid=1`)后,尝试为 `PAID`、订单 `pay_status=1`,订单 `state/delivery_status` 不变。
+- 验签通过且 `RtnCode!=1` 后,尝试为 `FAILED`、订单仍未付款,下一次 create 可生成新尝试。
+- 相同通知重复重放仍返回 `1|OK`,不重复推进任何订单业务状态。
+- 修改金额、商户号、检查码或删除任何实际字段后返回 `0|ERROR`,支付和订单事实不变。
+- 每次请求都在 `ipn_log` 新增 `type=omg` 的完整原文,包括验签失败请求。
+
+本阶段不验收查询、补单、取号、退款或推送;不得把旧 OMG 回调或旧查询结果作为新流程证据。

+ 14 - 4
specs/020-omg-payment-rebuild/research.md

@@ -15,7 +15,17 @@
 - `PaymentType=aio`、`EncryptType=1`、`InvoiceMark=N`、`NeedExtraPaidInfo=Y`。
 - ATM、CVS、BarcodeATM 的期限字段分别为 `ExpireDate=1`、`StoreExpireDate=30`、`BarcodeATMExpireDate=1`。
 - 创建请求中除 `CheckMacValue` 自身外,实际发送的每个字段都参加检查码计算。
-- 后续回调验签也必须包含 OMG 实际返回的全部字段;开启额外信息后,额外字段及空值字段同样不能被过滤。本阶段不实现回调。
+- 回调验签必须包含 OMG 实际返回的全部字段;开启额外信息后,额外字段、未知字段及空值字段同样不能被过滤,只有 `CheckMacValue` 排除。
+
+## 8. 付款结果通知结论(2026-08-13)
+
+- OMG 以 Server POST 向 `ReturnURL` 发送最终付款结果;特店验证并正确处理后必须回复纯文本 `1|OK`,否则回复 `0|ErrorMessage`,OMG 会重试通知。
+- `RtnCode=1` 是付款成功,其余代码均为异常;错误代码持续新增,因此保存原始 `RtnCode/RtnMsg`,不建立固定失败码枚举。
+- `SimulatePaid=1` 官方表示模拟付款。项目测试阶段经业务批准仍把它同步为已付款,但必须保留标记便于识别。
+- `NeedExtraPaidInfo=Y` 增加的所有回传字段都参加 CheckMacValue,包括官方示例中的空值字段。
+- ATM 的 `RtnCode=2` 与 CVS/BarcodeATM 的 `10100073` 是独立 `PaymentInfoURL` 取号通知,不属于本期 `/pay/omg/notify` 最终付款结果;当前创建表单没有发送 `PaymentInfoURL`。
+- 回调凭证使用创建尝试的 `HashKey/HashIV` 快照,避免门店一行凭证后来被覆盖导致在途交易无法验证。
+- 每次 HTTP 通知写入现有 `ipn_log`,完整请求也直接输出应用日志;数据库密钥不进入任何日志。
 - 查询 API 的 `TimeStamp` 三分钟有效期只是查询请求的防重放规则,不是创建表单或支付尝试的有效期,不能据此自动换新交易号。
 
 ## 2. 检查码算法
@@ -89,16 +99,16 @@
 
 ## 7. 新表与状态
 
-新表 `pos_order_omg_attempt` 记录本地可确认事实:业务订单、交易号、门店、MerchantID 快照、金额、状态和时间。
+新表 `pos_order_omg_attempt` 记录本地可确认事实:业务订单、交易号、门店、MerchantID/密钥快照、金额、状态、网关结果和时间。
 
-首阶段唯一状态为 `0=CREATED`。它只表示本地表单事实已生成并持久化,不能解释为 OMG 已收单、已付款或未付款。表内不保存 HashKey、HashIV、CheckMacValue 或完整表单。
+状态为 `0=CREATED, 1=PAID, 2=FAILED, 3=SUPERSEDED`。`CREATED` 只表示本地表单事实已生成并持久化;`PAID` 是不可逆资金事实;`FAILED` 可被后续成功通知升级;`SUPERSEDED` 释放同订单的其他活动入口。表内保存创建时的 HashKey/HashIV 快照,但不保存 CheckMacValue 或完整创建表单。
 
 ## 8. 配置边界
 
 - 网关地址在新代码中固定为 stage 完整端点,不提供正式地址回退。
 - 新配置前缀为 `omgpay`,只配置受控 HTTPS `return-url`。
 - `return-url` 必须无 user-info、query 和 fragment,路径必须精确为 `/pay/omg/notify`。
-- 第一阶段不注册 `/pay/omg/notify` 处理器;测试通知预期不会改变订单状态
+- `/pay/omg/notify` 由新 `omgpay` Controller 注册,只处理最终付款结果;不处理 `PaymentInfoURL` 取号通知
 
 ## 9. API 与错误
 

+ 73 - 20
specs/020-omg-payment-rebuild/spec.md

@@ -1,12 +1,12 @@
-# Feature Specification: OMG AIO 支付重建——创建支付订单
+# Feature Specification: OMG AIO 支付重建——创建支付与付款结果回调
 
 **Feature Branch**: `020-omg-payment-rebuild`
 
 **Created**: 2026-08-13
 
-**Status**: Implemented and automated verification passed; pending developer-applied DDL and manual stage acceptance
+**Status**: 创建支付已实现;付款结果回调设计已批准并进入实现
 
-**Input**: 以 OMG 全方位金流 AIO 官方技术文件 V1.5.3(2026-07)为唯一外部事实来源,从零重建创建支付订单;现有 OMG 支付代码、旧支付流水和 `specs/016-omg-payment` 均不作为需求或设计依据。第一阶段只完成测试环境首次创建并进入 OMG 收银台,其余能力后续逐项重建。
+**Input**: 以 OMG 全方位金流 AIO 官方技术文件 V1.5.3(2026-07)为唯一外部事实来源,从零重建创建支付订单和 `ReturnURL` 付款结果回调;现有 OMG 支付代码、旧支付流水和 `specs/016-omg-payment` 均不作为需求或设计依据。查询、补单、退款与推送后续逐项重建。
 
 ## 1. Scope and Trust Boundary
 
@@ -17,17 +17,19 @@
 - 根据 OMG 官方 AIO 规则生成完整表单和 `CheckMacValue`。
 - 由客户端在当前页面以表单 POST 进入 OMG 测试收银台。
 - 防止同一业务订单同时产生多个未结束 OMG 支付尝试。
+- 在 `POST /pay/omg/notify` 接收 OMG 最终付款结果,按创建尝试的凭证快照验签并幂等更新支付事实。
+- 每次回调独立写入现有 `ipn_log`,并保存完整、可重放的 form-urlencoded 回传内容。
+- 成功回调只把订单 `payStatus` 更新为已付款,不推进订单、配送或推送流程。
 - 停用旧 OMG Controller 及其补单、退款、定时任务和订单取消调用入口。
 
 ### 1.2 Explicitly out of scope
 
-- `ReturnURL` 付款结果通知的接收、验签、核销和订单状态变更。
 - ATM、CVS、BarcodeATM 取号结果通知及缴费信息展示。
 - `OrderResultURL`、`PaymentInfoURL`、`ClientRedirectURL`、`ClientBackURL`。
 - OMG 订单查询、自动补单、人工补单。
 - 退款、取消交易、信用卡关账。
 - 分期、定期定额、记忆卡号、银联专用流程。
-- 正式环境开放和真实付款完成验收
+- 正式环境开放。
 - 旧 OMG 支付流水的数据迁移、兼容或清理。
 - 客户端页面代码;本阶段只定义客户端必须遵守的表单 POST 契约。
 
@@ -46,10 +48,12 @@
 - `ChoosePayment` 固定为 `ALL`;具体显示渠道以该门店在 OMG 后台实际开通的能力为准。
 - 客户端在当前页面提交表单,不使用 iframe,不打开新窗口。
 - 当前仅接 OMG 测试环境。
-- 新可信公开路径继续使用 `/pay/omg/*`;创建入口为 `POST /pay/omg/create`,后续可信回调仍预定为 `POST /pay/omg/notify`。
+- 新可信公开路径继续使用 `/pay/omg/*`;创建入口为 `POST /pay/omg/create`,可信回调为 `POST /pay/omg/notify`。
 - 旧 Controller 完全作废,不保留 `/legacy/*` 或任何其他旧 OMG 接口。
 - 所有新实现代码放在新的 `omgpay` 包目录;新代码不得引用旧支付 Controller、旧签名器、旧表单工具或旧支付流水服务。
 - 新支付尝试使用全新表 `pos_order_omg_attempt`。
+- 创建时把该次尝试实际使用的 `HashKey / HashIV` 保存为凭证快照;回调不读取可能已被覆盖的新凭证。
+- `ipn_log` 是可信的通用 IPN 流水表,但旧 OMG 写入逻辑不可信;新回调只复用其现有表结构和通用插入 Service。
 
 ## User Scenarios & Testing
 
@@ -57,7 +61,7 @@
 
 已登录用户为自己的单门店餐饮订单选择 OMG 后,调用创建接口并取得由服务端签名的表单。客户端在当前页面 POST 该表单,进入对应门店的 OMG 测试收银台,并看到 `ALL` 下该门店已开通的付款方式。
 
-**Why this priority**: 这是本阶段唯一交付的用户价值,也是后续回调、查询和退款的前置能力。
+**Why this priority**: 这是进入 OMG 收银台的基础,也是本期最终付款回调及后续查询、退款的前置能力。
 
 **Independent Test**: 为测试门店配置有效测试凭证,创建一笔合法未支付订单,调用接口并提交响应表单,确认浏览器进入官方测试端点且收银台显示正确订单金额及可用渠道。
 
@@ -101,6 +105,27 @@
 4. **Given** 服务配置不是允许的 OMG 测试端点,**When** 调用创建接口,**Then** 系统拒绝生成表单。
 5. **Given** 创建成功或失败,**When** 检查 API 响应和应用日志,**Then** 所有响应和日志均不存在 `HashKey`、`HashIV`;完整 `CheckMacValue` 与签名表单只存在于订单本人获准取得的创建成功响应,不出现在错误响应或日志中。
 
+---
+
+### User Story 4 - 可信接收最终付款结果 (Priority: P1)
+
+OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传原文,使用创建支付时的门店凭证快照验证全部实际字段,并幂等同步支付尝试与订单付款状态。
+
+**Why this priority**: 创建支付后必须依靠可信 Server POST 确认资金事实;客户端跳转、旧回调和本地推测都不能证明付款结果。
+
+**Independent Test**: 对同一 `MerchantTradeNo` 分别提交合法成功、模拟成功、合法失败、重复、失败后成功、成功后失败、金额不符、商户不符和验签失败的 form-urlencoded 请求,确认支付尝试状态、订单 `payStatus`、`ipn_log` 流水和纯文本响应符合契约。
+
+**Acceptance Scenarios**:
+
+1. **Given** 回调字段完整且签名、`MerchantID`、`MerchantTradeNo`、`TradeAmt` 均匹配创建快照,**When** `RtnCode=1`,**Then** 支付尝试标记 `PAID`,订单只把 `payStatus` 改为 `1`,并返回精确的 `1|OK`。
+2. **Given** 合法成功回调的 `SimulatePaid=1`,**When** 当前系统仍处于测试阶段,**Then** 仍按已付款处理,同时在尝试记录和日志中保留模拟付款标记。
+3. **Given** 回调验签通过但 `RtnCode!=1`,**When** 处理最终失败结果,**Then** 尝试标记 `FAILED` 并保存原始 `RtnCode/RtnMsg`,订单保持未付款,活动尝试被释放,返回 `1|OK`。
+4. **Given** 同一尝试先失败后成功,**When** 后续成功通知到达,**Then** 状态从 `FAILED` 升级为 `PAID`;已 `PAID` 的尝试不得被后续失败通知降级。
+5. **Given** 订单已取消但收到合法成功通知,**When** 处理真实资金事实,**Then** 尝试仍标记 `PAID`、订单仍更新为已付款,并记录严重异常日志;本阶段不自动退款。
+6. **Given** 同一成功或失败通知重复到达,**When** 系统已处理相同事实,**Then** 不重复修改订单或推进业务状态,并返回 `1|OK`。
+7. **Given** 回调无法验证或持久化,**When** 交易号不存在、商户/金额不符、验签失败、字段非法或业务事务失败,**Then** 不修改订单或支付尝试,并返回 `0|ERROR` 以允许 OMG 重试。
+8. **Given** 任意回调请求到达,**When** 后续验签或业务事务失败,**Then** 本次完整回传内容仍以独立事务新增到 `ipn_log`;日志表故障不得阻断真实支付处理。
+
 ### Edge Cases
 
 - 业务订单号含有不适合 OMG `MerchantTradeNo` 的字符时,系统使用独立生成的英数字编号,不直接拼接或截断业务订单号。
@@ -110,7 +135,9 @@
 - 订单或凭证在并发过程中发生变化时,最终写入必须仍满足订单合法状态、凭证归属门店和单活跃尝试约束。
 - `TradeDesc`、`ItemName` 不接受客户端文本,必须由服务端生成,无 HTML 标签或未经允许的特殊符号,并满足官方长度限制。
 - `ReturnURL` 必须是服务端受控的 HTTPS URL,路径固定指向新的 `/pay/omg/notify`;客户端不得覆盖。
-- 第一阶段没有 `/pay/omg/notify` 处理器是预期行为;测试付款通知不会被旧回调接收或改变订单状态。
+- 表单中出现重复参数名、无法解码的 percent encoding 或超出受控大小时,按非法请求处理,避免参数污染或资源滥用。
+- OMG 新增未列明的回传字段时,只要请求合法,字段也必须被 DTO 边界完整捕获并参加验签;不能依赖固定字段白名单计算检查码。
+- 同一订单存在另一笔活动尝试时,一笔成功将关闭其他活动尝试;以后若另一历史交易也收到合法成功通知,仍记录其支付事实并输出严重异常日志,不吞掉第二笔资金事实。
 
 ## Requirements
 
@@ -131,22 +158,32 @@
 - **FR-013**: 创建表单 MUST NOT 发送 `PlatformID`、`PaymentInfoURL`、`OrderResultURL`、`ClientRedirectURL`、`ClientBackURL`、`Language`、分期、定期定额、记忆卡号或银联专用参数。
 - **FR-014**: `MerchantTradeDate` MUST 以 `Asia/Taipei` 时区格式化为 `yyyy/MM/dd HH:mm:ss`。
 - **FR-015**: `TradeDesc` 和 `ItemName` MUST 由服务端生成,禁止 HTML,符合 OMG 字符及长度限制;`ItemName` 不得超过中文 60 字或英数字 120 字的官方显示限制,字段总长度不得超过官方 `String(200)` 限制。
-- **FR-016**: `ReturnURL` MUST 是受控 HTTPS 地址并固定以 `/pay/omg/notify` 结尾;第一阶段只把它作为 OMG 必填值,不实现该路径的处理器
+- **FR-016**: `ReturnURL` MUST 是受控 HTTPS 地址并固定以 `/pay/omg/notify` 结尾;该路径 MUST 由新的 `omgpay` Controller 处理,不得被旧 Controller 接收
 - **FR-017**: `CheckMacValue` MUST 严格按 OMG 官方规则生成:排除 `CheckMacValue` 本身,将其余全部实际发送字段按官方字母顺序排序,以 `&` 串接,前置 `HashKey=...&`、后置 `&HashIV=...`,执行符合官方 .NET 表的 URL 编码并转小写,使用 SHA-256,最后输出大写十六进制。
 - **FR-018**: 除 `CheckMacValue` 自身外,创建请求实际发送的全部字段 MUST 参加签名,包括 `NeedExtraPaidInfo=Y` 和三个期限字段;不得挑选所谓核心字段计算。
-- **FR-019**: 后续回调阶段 MUST 遵守同一完整字段原则:除 `CheckMacValue` 外,OMG 实际返回的全部字段均参加验签;启用 `NeedExtraPaidInfo=Y` 后,全部额外回传字段及空值字段也必须进入验签集合。本阶段只固化该约束,不实现回调
+- **FR-019**: 回调 MUST 遵守同一完整字段原则:除 `CheckMacValue` 外,OMG 实际返回的全部字段均参加验签;启用 `NeedExtraPaidInfo=Y` 后,全部额外回传字段、未知字段及空值字段也必须进入验签集合。重复参数名必须作为非法请求拒绝,不得静默选取其中一个值
 - **FR-020**: 创建成功响应 MUST 使用明确对象 `{status, gatewayUrl, formFields}`;`status` 固定为 `CREATED`,`gatewayUrl` 为测试 AioCheckOut 端点,`formFields` 含实际需要 POST 的全部字段但不含 `gatewayUrl`。
 - **FR-021**: 客户端 MUST 在当前页面以 `application/x-www-form-urlencoded` 表单 POST 全部 `formFields` 到 `gatewayUrl`;不得使用 iframe,不得另开新窗口,不得把响应转换为 GET 查询链接。
-- **FR-022**: 系统 MUST NOT 在数据库保存 `HashKey`、`HashIV`、完整 `CheckMacValue` 或整份签名表单;支付尝试只保存本地可确认事实和创建快照
-- **FR-023**: 系统 MUST NOT 在任何响应或日志中输出登录 token、`HashKey`、`HashIV`。完整 `CheckMacValue` 和签名表单只允许出现在通过归属及业务校验的创建成功响应中,不得出现在错误响应或日志中。日志可记录本地支付尝试 ID、脱敏后的 `MerchantTradeNo`、业务订单号和阶段结果
+- **FR-022**: 支付尝试 MUST 保存创建时实际使用的 `HashKey / HashIV` 快照,确保门店凭证被覆盖或停用后仍可验证在途交易;不得保存完整创建表单。完整回调内容保存到现有 `ipn_log`,不新增该表字段
+- **FR-023**: 创建接口和创建日志 MUST NOT 输出登录 token、`HashKey`、`HashIV`。回调入口按已批准的排障策略 MUST 在应用日志和 `ipn_log.ipn_log` 直接记录完整回传内容,包括完整 `MerchantTradeNo`、`TradeNo`、额外参数、空值和 `CheckMacValue`;任何日志均不得输出数据库中的 `HashKey / HashIV`
 - **FR-024**: 业务校验错误 MUST 使用项目国际化机制,不得硬编码单一语言错误;新增错误 key 必须同步 `vi/zh/tw/en` 支持来源。
 - **FR-025**: 旧 `OmgPayController` MUST 取消 Spring Controller 身份且所有旧接口不可访问;不得保留 legacy 路径。
 - **FR-026**: 旧 OMG 定时补单任务、订单取消链路中的旧 OMG 退款调用、旧管理端 OMG 补单/退款接口和其他对旧 Controller 的运行时调用 MUST 一并停用或移除;不得影响非 OMG 订单取消及其他支付通道。
 - **FR-027**: 在开发者删除旧 OMG 支付及退款表后,任何可达运行链路 MUST NOT 再查询或写入这些旧表;旧表对应的 Mapper/Service 即使暂时保留源码,也不得被新流程或当前有效业务入口调用。`pos_store_omg` 凭证表不属于该禁用范围。
 - **FR-028**: 所有新建表、索引或约束 SQL MUST 只追加到 `updatesql/sql.md`,实现过程不得执行数据库变更。
-- **FR-029**: 第一阶段 MUST 保持测试环境边界,验收不得把旧回调或旧查询结果当成新流程成功证据。
+- **FR-029**: 创建与回调 MUST 保持测试环境边界,验收不得把旧回调或旧查询结果当成新流程成功证据。
 - **FR-030**: 新 `omgpay` 代码 MUST 包含标准且必要的注释:公开类型说明职责和安全边界;协议字段、检查码编码、事务与数据库唯一约束等非显然逻辑说明“为什么”;不为显然的赋值、访问器或框架样板添加重复注释。注释不得包含真实凭证、完整签名原文或可用测试秘密。
 - **FR-031**: 新创建流程 MUST 使用项目日志框架输出足够的结构化排障上下文。创建开始记录业务订单号和请求用户标识;校验通过后记录门店 ID;落库成功记录支付尝试 ID、业务订单号、门店 ID、金额、状态和脱敏 `MerchantTradeNo`;业务拒绝记录稳定业务错误码及已有的安全上下文;非预期异常记录相同安全上下文并保留服务端异常堆栈。不得以拼接整份 DTO、凭证对象或表单对象的方式记录日志。
+- **FR-032**: `POST /pay/omg/notify` MUST 是无需登录 token 的第三方 form-urlencoded 接口,Controller MUST 使用明确 DTO 作为唯一业务入参,不得使用 `Map` 或 `HttpServletRequest` 作为 Controller 入参。DTO 边界 MUST 保留全部实际参数、空值、原始可重放表单内容和请求 IP。
+- **FR-033**: 每次回调 MUST 先以独立事务向现有 `ipn_log` 新增一行:`type="omg"`、`ip` 为请求来源、`cretim` 为接收时间、`ipn_log` 为完整可重放 form-urlencoded 内容。该写入失败只记录应用错误日志,不得阻断后续验签与付款处理。
+- **FR-034**: 回调 MUST 先按 `MerchantTradeNo` 读取并锁定新支付尝试,再使用该尝试的 `HashKey / HashIV` 快照验签;不得只凭请求 `MerchantID` 选择密钥,也不得使用门店当前可能已变更的凭证。
+- **FR-035**: 验签成功后 MUST 同时校验请求 `MerchantID`、`MerchantTradeNo`、`TradeAmt` 与尝试快照完全一致。交易号不存在、字段缺失/格式非法、商户或金额不符、验签失败及业务事务异常 MUST NOT 修改订单或尝试,并返回纯文本 `0|ERROR`。
+- **FR-036**: 当 `RtnCode=1` 时,无论 `SimulatePaid` 为 `0` 或 `1`,系统 MUST 把尝试标记 `PAID` 并保存网关交易事实;订单仅把 `pay_status` 从未付款改为已付款,不得修改 `state`、`delivery_status` 或触发接单、出餐、完成、推送、退款。
+- **FR-037**: 当验签通过且 `RtnCode!=1` 时,系统 MUST 把非 `PAID` 尝试标记 `FAILED`,原样保存 `RtnCode/RtnMsg` 并释放活动尝试;订单保持未付款,下一次创建支付生成新的 `MerchantTradeNo`。
+- **FR-038**: 回调状态 MUST 成功优先且不可逆:`CREATED/FAILED -> PAID`,`CREATED -> FAILED`,`PAID` 不得降级。重复通知不得重复改变订单;正确处理或已处理的通知均返回精确 `1|OK`。
+- **FR-039**: 合法成功通知到达时,即使订单已取消也 MUST 记录付款事实并把订单 `pay_status` 更新为 `1`;系统 MUST 输出异常日志,但本阶段不得自动退款。
+- **FR-040**: 一笔尝试成功后 MUST 关闭同订单其他仍活动的尝试。若历史尝试后来也收到合法成功通知,仍 MUST 记录第二笔付款事实并输出严重异常日志,不得因本地单活跃约束丢弃真实资金通知。
+- **FR-041**: 支付尝试 MUST 保存 `trade_no`、`rtn_code`、`rtn_msg`、`payment_type`、`payment_date`、`trade_date`、`payment_type_charge_fee`、`simulate_paid`、`last_notify_time` 和最终回调原文;`trade_no` 非空时全局唯一,防止同一 OMG 交易被绑定到多个本地尝试。
 
 ### API Contract
 
@@ -201,7 +238,7 @@ token: <login-token>
 
 #### `OmgPaymentAttempt` / `pos_order_omg_attempt`
 
-表示本次重建产生的一次不可覆盖的 OMG 支付尝试。第一阶段字段如下:
+表示本次重建产生的一次不可覆盖的 OMG 支付尝试。创建和最终付款回调字段如下:
 
 | Field | Meaning | Constraint |
 |---|---|---|
@@ -211,8 +248,17 @@ token: <login-token>
 | `store_id` | 创建时订单门店 | 非空 |
 | `merchant_id` | 创建时门店 MerchantID 快照 | 非空、≤10 位 |
 | `amount` | 创建时订单整数 TWD 金额快照 | 非空、>0 |
-| `attempt_status` | 本地事实状态 | `0=CREATED` |
+| `hash_key_snapshot` | 创建时 HashKey 快照 | 非空,不得输出到日志或 API |
+| `hash_iv_snapshot` | 创建时 HashIV 快照 | 非空,不得输出到日志或 API |
+| `attempt_status` | 本地事实状态 | `0=CREATED, 1=PAID, 2=FAILED, 3=SUPERSEDED` |
 | `active_dd_id` | 单活跃约束生成列 | `attempt_status=0` 时为 `dd_id`,否则为 `NULL` |
+| `trade_no` | OMG 金流交易编号 | 可空;非空时全局唯一 |
+| `rtn_code` / `rtn_msg` | OMG 原始结果码及说明 | 可空,不维护固定错误码枚举 |
+| `payment_type` | OMG 回覆付款方式 | 可空 |
+| `payment_date` / `trade_date` | OMG 付款及建单时间 | 可空,Asia/Taipei 语义 |
+| `payment_type_charge_fee` | OMG 回传手续费 | 可空 |
+| `simulate_paid` | 模拟付款标记 | 可空;`1` 仍按已付款处理 |
+| `last_notify_time` | 最近一次合法通知处理时间 | 可空 |
 | `create_time` | 本地创建时间 | 非空 |
 | `update_time` | 本地更新时间 | 非空 |
 
@@ -220,12 +266,17 @@ token: <login-token>
 
 - `UNIQUE (merchant_trade_no)`;
 - `UNIQUE (active_dd_id)`,利用 MySQL 唯一索引允许多个 `NULL` 的语义,为后续终态释放活跃键;
-- `CREATED` 只表示本地已生成并持久化表单所需事实,不能解释为 OMG 已接收、已建立订单或未付款;
-- 后续回调/查询规格负责定义可信的后续状态;本阶段不得预先猜测完整状态机。
+- `CREATED` 只表示本地已生成并持久化表单所需事实,不能解释为 OMG 已接收或已建立订单;
+- `PAID` 是不可逆资金事实;`FAILED` 可被后续合法成功通知升级为 `PAID`;`SUPERSEDED` 表示同订单已有其他尝试成功,并非 OMG 返回的支付失败;
+- 回调保存归一化最终事实,逐次完整请求历史由 `ipn_log` 承载。
 
 #### Existing `PosStoreOmg` / `pos_store_omg`
 
-可信门店凭证来源。本阶段不修改其表结构、录入流程或启停流程。新创建服务只读取与订单 `storeId` 对应、已启用的 `MerchantID / HashKey / HashIV`。
+可信门店凭证来源。不修改其表结构、录入流程或启停流程。创建服务读取与订单 `storeId` 对应、已启用的 `MerchantID / HashKey / HashIV`,并把密钥写入本次尝试快照;回调不再依赖门店当前行。
+
+#### Existing `IpnLog` / `ipn_log`
+
+每个回调 HTTP 请求新增一行,不新增字段:`ip` 保存来源地址,`cretim` 保存接收时间,`type` 固定为 `omg`,`ipn_log` 保存完整原始 form-urlencoded 请求体。该流水使用独立事务,后续业务回滚不删除已经接收的通知记录。
 
 ## 3. Error Handling and Security
 
@@ -247,7 +298,9 @@ token: <login-token>
 - 使用 OMG 官方 AioCheckOut 示例参数和官方期望 `CheckMacValue` 验证完整 SHA-256 签名链路;不得用旧实现测试或其他金流向量代替官方依据。
 - 验证字段排序、HashKey/HashIV 包夹、.NET URL 编码替换、转小写、SHA-256 和大写十六进制各步骤。
 - 验证实际发送字段集合与签名输入集合完全一致;删除、增加或修改任一非 `CheckMacValue` 字段都会改变签名。
-- 验证空值字段在未来回调验签集合中不会被静默删除;本阶段至少通过签名器单元测试锁定“保留空值”的通用能力。
+- 验证回调所有实际字段、未知额外字段和空值字段均进入签名集合,只有 `CheckMacValue` 被排除。
+- 验证 `ipn_log` 在验签失败和业务事务回滚时仍独立保留,且日志写入失败不阻断付款处理。
+- 验证成功、模拟成功、失败、失败后成功、成功后失败、重复通知、取消后成功和同订单迟到第二笔成功的状态机。
 - 验证 `MerchantTradeNo` 仅含英数字、长度不超过 20、重复冲突不会覆盖旧行。
 - 验证台北时区与 `yyyy/MM/dd HH:mm:ss` 格式。
 - 验证固定字段和值、禁止字段不出现在表单、金额只取订单、文字字段安全和长度限制。
@@ -275,7 +328,7 @@ token: <login-token>
 - 在当前页面将全部 `formFields` POST 到返回的测试 `gatewayUrl`。
 - 确认进入 OMG 测试收银台,金额、商品说明和 `ALL` 可用渠道显示正确。
 - 确认不使用 iframe 或新窗口。
-- 本阶段不以付款、回调、查询、取号或退款结果作为完成标准。
+- 创建和付款结果回调按本规格完成;查询、补单、取号、退款与推送不作为本阶段完成标准。
 
 ## Success Criteria
 

+ 34 - 3
specs/020-omg-payment-rebuild/tasks.md

@@ -1,10 +1,10 @@
-# Tasks: OMG AIO 创建支付订单重建
+# 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**: 只完成测试环境创建支付订单;不实现回调、查询、补单、退款、关账或正式环境。
+**Scope**: 完成测试环境创建支付订单和最终付款结果回调;不实现取号、查询、补单、退款、推送、关账或正式环境。
 
 ## Phase 1: 新持久化基础
 
@@ -81,9 +81,37 @@
 - [x] T046 运行 `git diff --check`、`git status --short`、`git diff --stat` 并确认无无关文件
 - [ ] T047 开发者手动执行 DDL 后,按 `quickstart.md` 验证首次创建、当前页 POST、重复、真实并发、负例和日志
 
+## Phase 7: 回调数据与凭证快照
+
+- [ ] T048 [US4] 更新 `pos_order_omg_attempt` DDL:新增 HashKey/HashIV 快照、`PAID/FAILED/SUPERSEDED` 状态、网关结果字段和 `trade_no` 唯一键;只写 `updatesql/sql.md`,不执行 SQL
+- [ ] T049 [US4] 先编写 Entity/Mapper/Service 合约测试源码,覆盖创建密钥快照、交易号锁定、成功不可逆、失败释放、其他活动尝试关闭与订单仅更新 `pay_status`
+- [ ] T050 [US4] 扩展 `OmgPaymentAttempt`、Mapper XML 和 `IOmgPaymentAttemptService`,实现带当前状态条件的原子更新
+- [ ] T051 [US4] 修改创建服务,把本次表单使用的 HashKey/HashIV 传入 `createCreated` 并持久化快照
+
+## Phase 8: 原始表单边界与 IPN 流水
+
+- [ ] T052 [US4] 先编写原始 form-urlencoded 解析测试源码,覆盖未知字段、空值、重复字段、非法编码、大小限制和可重放原文
+- [ ] T053 [US4] 实现 `OmgNotifyRequest` 与专用参数解析/ArgumentResolver;Controller 业务入参保持一个 DTO,不使用 Map 或 HttpServletRequest
+- [ ] T054 [US4] 先编写 IPN 独立事务测试源码,覆盖每次请求一行、`type=omg`、完整原文以及日志写入失败不阻断处理
+- [ ] T055 [US4] 在 `com.ruoyi.system.omgpay` 新建 `OmgIpnAuditService`,以 `REQUIRES_NEW` 复用 `IIpnLogService` 写入现有 `ipn_log`
+
+## Phase 9: 验签、状态机与公开回调
+
+- [ ] T056 [US4] 先编写回调服务测试源码,覆盖全部实际字段验签、快照密钥、商户/金额匹配、成功、模拟成功、失败、失败后成功、成功后失败、重复、取消后成功和第二笔迟到成功
+- [ ] T057 [US4] 实现 `OmgPaymentNotifyService`,在一个业务事务内锁定尝试、验签、校验并更新尝试与订单;不触发订单/配送状态、推送或退款副作用
+- [ ] T058 [US4] 先编写 Controller 契约测试源码,覆盖匿名 form POST、纯文本 `1|OK`/`0|ERROR`、完整请求日志和无旧回调引用
+- [ ] T059 [US4] 在新 `OmgPaymentController` 增加 `POST /pay/omg/notify`,先独立记录 IPN,再执行业务处理;所有请求完整记录,数据库密钥不入日志
+- [ ] T060 [US4] 静态审计 Controller 无 Map/HttpServletRequest 入参、回调只用新尝试表和可信 `ipn_log`/`pos_store_omg` 创建来源、无旧 OMG 业务引用
+
+## Phase 10: 回调文档与延后验证
+
+- [ ] T061 更新 `quickstart.md` 的回调重放示例、成功/失败/重复/模拟/取消后付款与 IPN 查询步骤
+- [ ] T062 运行 `git diff --check`、定向 `rg` 和 staged diff 审计;本阶段不运行 Maven、编译或测试
+- [ ] T063 在后续 OMG 查询、补单、退款等功能全部调整完成后,统一运行 JDK 21 定向测试、模块构建和完整回归
+
 ## Dependencies & Execution Order
 
-- Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6。
+- Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10
 - T008 与 T011/T012 可独立写测试,但实施时顺序执行以维持清晰 TDD 证据。
 - T031 必须早于任何脏文件修改;T041 必须在旧代码退役完成后执行。
 - 手动 stage 验收依赖开发者执行 DDL,自动测试和构建不依赖数据库变更。
@@ -91,6 +119,9 @@
 ## Completion Definition
 
 - `POST /pay/omg/create` 首次返回可提交的 stage 表单。
+- `POST /pay/omg/notify` 对所有实际字段验签,并幂等同步成功/失败支付事实。
+- 每次回调完整保存到现有 `ipn_log`;不新增该表字段。
+- 成功仅修改订单 `payStatus`,不推进业务状态或触发推送/退款。
 - 同订单重复/并发创建最多一条 `CREATED`,后续请求为 `PAYMENT_ATTEMPT_EXISTS`。
 - 官方检查码向量一致,实际发送字段无遗漏。
 - 新代码全在 `omgpay` 包,不引用旧 OMG 支付实现。