research.md 9.4 KB

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

Date: 2026-08-13 Payment-method decision updated: 2026-08-18 Decision source: OMG AIO 官方技术文件 V1.5.3 与已批准的 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 为:

{
  "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 与其凭证管理代码保持不变。

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