# 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=&`,后置 `&HashIV=`。 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":""})`。订单不存在和越权使用同一外部状态 `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` 与其凭证管理代码保持不变。 这项删除只负责阻止旧代码在旧表删除后被调用,不为回调、退款、补单或状态核销提供替代实现;这些能力必须在后续独立规格中重新设计。