# Research: OMG AIO 创建支付订单重建

**Date**: 2026-08-13
**Payment-method decision updated**: 2026-08-18
**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` 之类组合；`Credit` 与 `ApplePay` 是两个独立取值。
- `MerchantTradeNo` 必须唯一、不可重复使用、最多 20 个 ASCII 英数字。
- `PaymentType=aio`、`EncryptType=1`、`InvoiceMark=N`、`NeedExtraPaidInfo=Y`。
- `IgnorePayment` 仅在 `ChoosePayment=ALL` 时生效，官方公开可用值不含 AFTEE；测试环境又没有商户后台渠道开关，因此 `ALL` 无法满足“严格不显示 AFTEE”的需求。
- 创建请求中除 `CheckMacValue` 自身外，实际发送的每个字段都参加检查码计算。
- 回调验签必须包含 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. 检查码算法

决定实现新的 `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**: App 先选择 `CREDIT` 或 `APPLE_PAY`，后端白名单映射为 `ChoosePayment=Credit + UnionPay=2` 或 `ChoosePayment=ApplePay`；不发送 `ChoosePayment=ALL` 或 `IgnorePayment`。

**Rationale**: 用户要求测试环境只保留信用卡和 Apple Pay，但测试环境没有商户后台渠道开关。官方 `IgnorePayment` 无法排除 AFTEE，而一个请求也不能同时指定 `Credit` 与 `ApplePay`。因此必须在 App 自有界面先选择渠道，再由服务端生成单渠道签名表单；这样无需依赖第三方页面配置，也不会让客户端控制 OMG 原始参数。Apple Pay 是否能完成仍取决于门店开通状态和当前设备环境。

**Rejected**:

- `ALL + IgnorePayment`：只能隐藏 ATM、CVS、BarcodeATM，无法隐藏 AFTEE。
- 固定 `ChoosePayment=Credit`：能隐藏其他渠道，但会同时失去独立 Apple Pay 入口。
- 前端 CSS/DOM 隐藏第三方页面选项：脆弱且无法改变服务端实际允许的渠道，不作为安全边界。

## 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, 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` 由新 `omgpay` Controller 注册，只处理最终付款结果；不处理 `PaymentInfoURL` 取号通知。

## 9. API 与错误

`POST /pay/omg/create` 使用 `@RequestHeader String token` 和显式 `@RequestBody OmgCreatePaymentRequest`，DTO 只含 `orderId` 与受控 `paymentMethod`。retry 使用相同渠道字段；query/refund 仍只含 `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` 与其凭证管理代码保持不变。

这项删除只负责阻止旧代码在旧表删除后被调用，不为回调、退款、补单或状态核销提供替代实现；这些能力必须在后续独立规格中重新设计。
