spec.md 45 KB

Feature Specification: OMG AIO 支付重建——创建支付与付款结果回调

Feature Branch: 020-omg-payment-rebuild

Created: 2026-08-13

Status: 创建支付已实现;付款结果回调设计已批准并进入实现

Input: 以 OMG 全方位金流 AIO 官方技术文件 V1.5.3(2026-07)为唯一外部事实来源,从零重建创建支付订单和 ReturnURL 付款结果回调;现有 OMG 支付代码、旧支付流水和 specs/016-omg-payment 均不作为需求或设计依据。查询、补单、退款与通用支付推送后续逐项重建;2026-08-28 起仅追加外送订单支付成功后的骑手开放推送。

1. Scope and Trust Boundary

1.1 In scope

  • 从订单所属门店读取其独立的 MerchantID / HashKey / HashIV。
  • 为合法、未支付的 OMG 订单创建一条全新的支付尝试。
  • 根据 OMG 官方 AIO 规则生成完整表单和 CheckMacValue。
  • 由客户端在当前页面以表单 POST 进入 OMG 测试收银台。
  • 防止同一业务订单同时产生多个未结束 OMG 支付尝试。
  • 在 POST /pay/omg/notify 接收 OMG 最终付款结果,按创建尝试的凭证快照验签并幂等更新支付事实。
  • 在 POST /pay/omg/query 只查询订单当前有效支付尝试;可信已付款结果复用回调状态机补偿丢失回调。
  • 提供 POST /pay/omg/refund 的测试阶段拒绝契约:明确提示测试环境不支持退款,且不调用正式网关、不改本地状态。
  • 每次回调独立写入现有 ipn_log,并保存完整、可重放的 form-urlencoded 回传内容。
  • 成功回调只把订单 payStatus 更新为已付款,不推进订单或配送状态;外送订单支付成功后可触发骑手开放推送,其他通用支付推送仍不在本阶段实现。
  • 停用旧 OMG Controller 及其补单、退款、定时任务和订单取消调用入口。
  • 定义 iOS 用户端 pages/OrderList/buy/omgCheckout 通过当前原生 WebView 的 loaded 与固定结果 URL 接管返回流程。

1.2 Explicitly out of scope

  • ATM、CVS、BarcodeATM 取号结果通知及缴费信息展示。
  • PaymentInfoURL、ClientRedirectURL、ClientBackURL。
  • 定时/批量自动补单、独立人工补单入口;用户触发的当前支付查询补偿属于本阶段范围。
  • 正式环境退款动作、取消交易、信用卡关账;本阶段只实现测试环境安全拒绝入口。
  • 分期、定期定额、记忆卡号、银联专用流程。
  • 正式环境开放。
  • 旧 OMG 支付流水的数据迁移、兼容或清理。
  • 用户端 App 源码不在当前后端工作区;本仓库定义表单 POST 与 iOS 返回交接契约,实际页面修改和后续支付查询、提示及路由由前端实施。

1.3 Trust decisions

  • OMG 官方 AIO 技术文件 V1.5.3 是支付协议的唯一事实来源。
  • 旧 OmgPayController、旧 OMG 工具类、旧支付流水表和 specs/016-omg-payment 均不可信,不得复制其行为或用其解释官方文档。
  • 唯一获准复用的旧 OMG 能力是 pos_store_omg 及其现有门店凭证存储、录入、启停和查询实现。该表只保存每个门店独立的 MerchantID / HashKey / HashIV 等凭证信息。
  • 订单现有字段继续使用 PosOrder.payType = "2" 表示选择 OMG 支付。
  • 旧支付表及其数据不读取、不迁移;开发者会自行删除旧表。

2. Confirmed Business Decisions

  • 每个门店使用独立的 MerchantID / HashKey / HashIV,不使用平台统一凭证。
  • 不传 PlatformID。
  • App 在进入 OMG 前只提供 CREDIT 与 APPLE_PAY 两个受控选项;服务端分别映射为 ChoosePayment=Credit + UnionPay=2 与 ChoosePayment=ApplePay,不再进入 OMG 的 ALL 付款方式选择页。
  • 客户端在当前页面提交表单,不使用 iframe,不打开新窗口。
  • 当前仅接 OMG 测试环境。
  • 新可信公开路径继续使用 /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

User Story 1 - 首次创建并进入 OMG 收银台 (Priority: P1)

已登录用户为自己的单门店餐饮订单选择 OMG 后,先在 App 选择信用卡或 Apple Pay,再调用创建接口取得对应渠道的服务端签名表单。客户端在当前页面 POST 该表单,直接进入所选 OMG 测试付款流程,不展示包含超商快付或 AFTEE 的 OMG ALL 付款方式选择页。

Why this priority: 这是进入 OMG 收银台的基础,也是本期最终付款回调及后续查询、退款的前置能力。

Independent Test: 为测试门店配置有效测试凭证,创建一笔合法未支付订单,调用接口并提交响应表单,确认浏览器进入官方测试端点且收银台显示正确订单金额及可用渠道。

Acceptance Scenarios:

  1. Given 用户拥有一笔 payType="2"、未取消、未付款、金额为正的单门店订单,且门店 OMG 凭证已启用,When 用户首次调用创建接口,Then 系统创建唯一支付尝试并返回可提交的 OMG 表单。
  2. Given 创建接口返回成功,When 客户端在当前页面向 gatewayUrl POST 全部 formFields,Then 浏览器进入 OMG 测试收银台,不通过 iframe 或新窗口加载。
  3. Given 用户选择信用卡,When 创建并提交表单,Then 服务端发送并签名 ChoosePayment=Credit 与 UnionPay=2,页面不得显示超商快付、AFTEE、Apple Pay 或银联选择项。
  4. Given 用户选择 Apple Pay,且门店与设备支持 Apple Pay,When 创建并提交表单,Then 服务端只发送并签名 ChoosePayment=ApplePay,直接进入 Apple Pay 流程,不显示信用卡、超商快付或 AFTEE 选择项。
  5. Given paymentMethod 缺失或不属于 CREDIT/APPLE_PAY,When 调用 create 或 retry,Then 服务端返回稳定错误 PAYMENT_METHOD_INVALID,且不创建或替换支付尝试。

User Story 2 - 阻止重复创建有效付款入口 (Priority: P1)

客户创建表单后没有立即付款,再次点击支付时,系统既不重复提交旧 MerchantTradeNo,也不贸然生成新的 MerchantTradeNo。

Why this priority: ATM、CVS、BarcodeATM 可能在较长期限内仍可付款;多个有效入口可能造成重复付款。

Independent Test: 对同一订单顺序或并发调用创建接口,数据库始终只有一条未结束尝试,后续请求得到 PAYMENT_ATTEMPT_EXISTS,且不会返回第二份可提交表单。

Acceptance Scenarios:

  1. Given 某订单已有 CREATED 尝试,When 用户再次创建,Then 返回 PAYMENT_ATTEMPT_EXISTS,不生成新 MerchantTradeNo,也不重放旧表单。
  2. Given 同一订单同时发起多个创建请求,When 请求并发执行,Then 数据库约束和事务保证最多一条未结束尝试,其余请求得到一致的业务结果。
  3. Given 旧尝试状态尚不明确,When 系统尚未实现可信查询,Then 不以本地时间、新鲜窗口或用户重复点击为理由释放旧尝试。

User Story 3 - 拒绝不合法或不安全的创建请求 (Priority: P1)

系统只为订单本人、合法业务状态、合法金额且门店凭证可用的测试订单创建支付尝试,并确保门店密钥不出现在响应或日志中。

Why this priority: 创建错误门店、错误金额或泄露密钥会形成直接资金风险。

Independent Test: 分别使用无效 token、他人订单、终态订单、异常金额、错误支付类型、非法 paymentMethod、无凭证门店和非测试网关配置调用接口,均被拒绝且不写入尝试表。

Acceptance Scenarios:

  1. Given 请求用户不是订单所有者,When 调用创建接口,Then 系统拒绝且不透露订单或门店支付详情。
  2. Given 订单已取消、已付款、金额不大于零、不是单门店订单、payType 不是 "2" 或 paymentMethod 非法,When 调用创建接口,Then 系统返回国际化业务错误且不创建尝试。
  3. Given 门店没有已启用 OMG 凭证,When 调用创建接口,Then 系统拒绝且不创建尝试。
  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;日志表故障不得阻断真实支付处理。

User Story 8 - iOS 支付结果页返回 App 逻辑层 (Priority: P1)

iOS 用户在 OMG 收银台完成或结束支付流程后,OMG 加载后端 /pay/omg/result。专用页面 pages/OrderList/buy/omgCheckout 监听当前页面原生 WebView 的加载地址,识别固定结果 URL 后只触发一次 App 业务交接;后端现有结果页、按钮和 Scheme 继续作为兼容兜底。

Why this priority: 远程结果页在同 App WKWebView 中调用 Bridge、父 WebView 或 Scheme 已有真机失败证据;如果 App 逻辑层不能接管,iOS 用户会停留在返回页。

Independent Test: 在 iPhone 中完成一笔 OMG stage 支付,确认当前页面 WebView 加载 /pay/omg/result 后仅出现一次 result_page_detected;中间页、取消页和其他 URL 不触发;Android 和外部浏览器的现有返回页按钮及 Scheme 不受影响。

Acceptance Scenarios:

  1. Given omgCheckout 已经提交 OMG Form 并绑定当前页面 WebView 的 loaded,When 当前 URL 精确进入 /pay/omg/result,Then App 逻辑层只触发一次 handleOmgReturnDetected({ orderId, currentUrl })。
  2. Given 当前 WebView 正在加载 OMG 收银台、中间页、取消页或其他地址,When loaded 触发,Then App 不产生 OMG_RETURNED 交接信号。
  3. Given 后端返回现有结果 HTML,When iOS 主监听接管成功或从 Android/外部浏览器访问,Then 原提示、“返回 App”按钮和 App Scheme 仍可作为非关键兜底保留。
  4. Given App 已识别结果 URL,When 前端开始后续处理,Then 不得只凭结果页到达宣称支付成功,支付查询和最终路由由前端业务实现。

Edge Cases

  • 支付成功回调丢失时,用户查询当前有效尝试;完整验签及商户号、交易号、金额核对后必须原子补偿为已付款。
  • 查询返回未付款时不得修改本地状态;返回 10200095 时同步失败信息;任何查询不得把已付款降级。
  • 查询响应新增字段或空值字段仍必须参与检查码计算,客户端不得指定要查询的 MerchantTradeNo。
  • 测试环境调用退款时必须在任何支付/订单读取、网关 HTTP 或状态写入前失败关闭,返回稳定的环境不支持状态。

  • 业务订单号含有不适合 OMG MerchantTradeNo 的字符时,系统使用独立生成的英数字编号,不直接拼接或截断业务订单号。

  • 随机生成的 MerchantTradeNo 发生唯一索引冲突时,系统可在同一创建事务中重新生成;达到受控次数仍失败时整笔创建回滚。

  • 客户端取得表单但未提交、网络中断或关闭页面时,本地只能保持 CREATED,不得宣称 OMG 已建立或未付款。

  • 表单生成成功但尝试落库失败,或尝试落库事务最终回滚时,不得向客户端返回可提交表单。

  • 订单或凭证在并发过程中发生变化时,最终写入必须仍满足订单合法状态、凭证归属门店和单活跃尝试约束。

  • TradeDesc、ItemName 不接受客户端文本,必须由服务端生成,无 HTML 标签或未经允许的特殊符号,并满足官方长度限制。

  • ReturnURL 必须是服务端受控的 HTTPS URL,路径固定指向新的 /pay/omg/notify;客户端不得覆盖。

  • 表单中出现重复参数名、无法解码的 percent encoding 或超出受控大小时,按非法请求处理,避免参数污染或资源滥用。

  • OMG 新增未列明的回传字段时,只要请求合法,字段也必须被 DTO 边界完整捕获并参加验签;不能依赖固定字段白名单计算检查码。

  • 同一订单存在另一笔活动尝试时,一笔成功将关闭其他活动尝试;以后若另一历史交易也收到合法成功通知,仍记录其支付事实并输出严重异常日志,不吞掉第二笔资金事实。

Requirements

Functional Requirements

  • FR-037: 查询接口 MUST 仅接受业务订单号并验证 token 用户归属;服务端 MUST 选择唯一当前 CREATED 尝试,已付款订单选择其首条 PAID 尝试。
  • FR-038: 查询 MUST 使用尝试保存的 MerchantID / HashKey / HashIV 快照向 OMG stage QueryTradeInfo/V5 发送表单请求。
  • FR-039: 查询响应 MUST 将全部实际回传参数(含额外与空值)纳入检查码计算,仅排除 CheckMacValue,并核对商户号、交易号与金额。
  • FR-040: TradeStatus=1 MUST 复用回调的锁与幂等状态机更新尝试和订单为已付款;0 MUST 不修改;10200095 MUST 同步失败;已付款 MUST 不可逆。
  • FR-041: 查询只返回白名单字段且不写 ipn_log;HashKey、HashIV、CheckMacValue 和完整网关响应 MUST NOT 返回客户端或写入应用日志。
  • FR-042: POST /pay/omg/refund MUST 需要 token,并以显式 JSON DTO 只接收 orderId;不得接收客户端提供的金额、交易号、Action、凭证或地址。
  • FR-043: 当前测试阶段退款 MUST 返回 PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT,MUST NOT 调用 OMG 正式 CreditDetail/DoAction、查询或写入任何订单/支付/退款状态。
  • FR-044: 测试环境退款拒绝 MUST 提供五套 i18n 提示和必要的脱敏日志;日志不得记录 token、凭证或完整支付签名。
  • FR-045: 系统 MUST 提供 POST /pay/omg/retry,使用 token 和只含 orderId、paymentMethod 的显式 JSON DTO;paymentMethod 仅允许 CREDIT/APPLE_PAY。客户端不得提交旧 MerchantTradeNo、OMG 原始支付参数、金额、凭证、网关地址或旧表单。
  • FR-046: retry MUST 先复用可信 query 查询 OMG 实际状态;查询失败、响应不可信或状态为 UNKNOWN 时不得修改尝试或创建新表单。
  • FR-047: query 确认 PAID 时 retry MUST 返回 ORDER_ALREADY_PAID;确认 FAILED 时 MAY 创建新尝试;确认 UNPAID 时仅当 paymentType 为空才 MAY 替换旧尝试。
  • FR-048: 替换未付款尝试 MUST 在同一事务内锁定订单,精确比较 query 已验证的 MerchantTradeNo 与当前活动尝试,把该行原子更新为 SUPERSEDED 后再生成新交易号和新表单;任一条件变化 MUST 返回 PAYMENT_RETRY_NOT_AVAILABLE。
  • FR-049: 同一旧尝试的顺序或并发 retry MUST 最多生成一条新 CREATED 尝试;迟到的旧尝试可信成功回调仍 MUST 能升级为 PAID 并关闭更新的活动尝试。
  • FR-050: retry 不保存、恢复或复用 App WebView,不返回旧表单或所谓继续付款 URL;成功响应 MUST 与 create 相同并包含新 MerchantTradeNo 的新表单。
  • FR-051: 查询响应 MUST 先验证所有实际回传字段的 CheckMacValue,并始终核对 MerchantID、MerchantTradeNo、TradeAmt 与 TradeStatus;缺少任一核心字段时 MUST fail closed。除 10200047 外,TradeAmt MUST 与本地尝试金额一致。
  • FR-052: StoreID、TradeNo、PaymentDate、PaymentType、HandlingCharge、PaymentTypeChargeFee、TradeDate、ItemName 与 CustomField1..4 不得仅因字段不存在而使 UNPAID、10200095 或 10200047 查询失败;实际存在的字段仍 MUST 参与验签。
  • FR-053: TradeStatus=1 MUST 继续要求可信结算所需的 TradeNo、PaymentDate、PaymentType、PaymentTypeChargeFee 与 TradeDate,不得因兼容稀疏未付款响应而放宽已付款事实校验。
  • FR-054: 查询字段缺失日志 MAY 记录缺失字段名称,但 MUST NOT 记录完整网关响应、CheckMacValue、HashKey、HashIV 或登录 token。
  • FR-055: 当已签名查询响应的商户号和交易号精确匹配、TradeStatus=10200047 且 TradeAmt=0 时,系统 MUST 将其视为网关不存在该交易并同步旧尝试失败,使 retry 创建新表单;该状态返回非零金额或任一身份、签名校验失败时 MUST fail closed。
  • FR-056: OMG 专用页面 pages/OrderList/buy/omgCheckout MUST 在 App-Plus iOS 获取并监听当前 this.$scope.$getAppWebview();该页面通过 renderjs 在当前 WebView 提交 Form,不得按 <web-view> 子组件使用 children()[0]。
  • FR-057: iOS 返回监听 MUST 精确匹配 https://foodieapi.waimai-paotui.com/pay/omg/result 及其 query/hash 形式;只包含 omg、result 或其他域名的地址 MUST NOT 触发交接。
  • FR-058: 同一次结果页返回 MUST 最多触发一次 handleOmgReturnDetected({ orderId, currentUrl });页面卸载时 MUST 移除原生 loaded 监听并清理引用。
  • FR-059: /pay/omg/result MUST 继续返回现有安全 HTML;繁体提示、“返回 App”按钮和 App Scheme MAY 作为 Android、外部浏览器或监听失效时的兼容兜底保留,但 iOS 主流程 MUST NOT 依赖 Bridge、parent.evalJS 或 Scheme 成功。
  • FR-060: 结果页 URL 到达只表示 OMG 浏览器流程返回,MUST NOT 作为付款成功事实;App 后续查询、提示和路由由前端业务实现。

  • FR-001: 系统 MUST 新建 com.ruoyi.app.omgpay 下的 Controller、请求/响应 DTO、创建服务、表单生成器、签名器和配置类型;这些新类 MUST NOT 引用旧 OmgPayController、旧 OmgPay、旧 OmgCheckMacValue 或旧 OMG 支付流水服务。

  • FR-002: 系统 MUST 新建 com.ruoyi.system.omgpay 下的支付尝试 Entity、Mapper 和 Service;新支付尝试 MUST 使用 pos_order_omg_attempt,不得读取或写入旧 OMG 支付流水表。

  • FR-003: 系统 MAY 复用现有 pos_store_omg 门店凭证查询实现,且这是唯一允许复用的旧 OMG 实现;新创建流程 MUST 按订单门店读取该门店已启用的 MerchantID / HashKey / HashIV。

  • FR-004: 系统 MUST 保持公开创建入口为 POST /pay/omg/create,使用 @RequestHeader String token 和显式 @RequestBody DTO;Controller 入参不得使用 Map,DTO 不使用 Bean Validation 注解。

  • FR-005: 创建请求 DTO MUST 只接收 orderId 与 paymentMethod;paymentMethod 仅允许 CREDIT/APPLE_PAY。金额、门店、用户、订单支付类型、说明文字、网关地址、回调地址及原始 OMG 参数均必须由服务端决定。

  • FR-006: 系统 MUST 校验登录用户为订单所有者,订单为单门店订单、未取消、未付款、未完成、金额为正且 PosOrder.payType="2";任何校验失败 MUST NOT 创建支付尝试。

  • FR-007: 系统 MUST 使用订单的整数新台币金额作为 TotalAmount,不得接受或信任客户端金额。

  • FR-008: 系统 MUST 仅允许创建表单到 https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5;第一阶段不得配置或回退到正式环境。

  • FR-009: 系统 MUST 为每次新尝试生成全局唯一、不可复用、长度不超过 20 且只含 ASCII 英数字的 MerchantTradeNo;不得从业务订单号直接派生可冲突或超长的编号。

  • FR-010: 同一业务订单在任一时刻 MUST 最多存在一条未结束 OMG 尝试;顺序重复或并发创建 MUST 返回业务状态 PAYMENT_ATTEMPT_EXISTS,不得返回旧表单、重复提交旧编号或创建新编号。

  • FR-011: 在可信查询尚未实现前,系统 MUST NOT 基于固定分钟窗口、本地创建时间或用户再次点击自动结束 CREATED 尝试。

  • FR-012: 创建表单公共字段 MUST 包含 MerchantID、MerchantTradeNo、MerchantTradeDate、PaymentType=aio、TotalAmount、TradeDesc、ItemName、ReturnURL、OrderResultURL、EncryptType=1、InvoiceMark=N、NeedExtraPaidInfo=Y 和 CheckMacValue。CREDIT 额外固定发送 ChoosePayment=Credit、UnionPay=2;APPLE_PAY 额外固定发送 ChoosePayment=ApplePay 且不发送 UnionPay。

  • FR-013: 创建表单 MUST NOT 发送 ChoosePayment=ALL、IgnorePayment、PlatformID、PaymentInfoURL、ClientRedirectURL、ClientBackURL、Language、ATM/CVS/BarcodeATM 期限字段、分期、定期定额或记忆卡号参数;APPLE_PAY 也 MUST NOT 发送仅适用于信用卡的 UnionPay。

  • 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 结尾;该路径 MUST 由新的 omgpay Controller 处理,不得被旧 Controller 接收。

  • FR-017: CheckMacValue MUST 严格按 OMG 官方规则生成:排除 CheckMacValue 本身,将其余全部实际发送字段按官方字母顺序排序,以 & 串接,前置 HashKey=...&、后置 &HashIV=...,执行符合官方 .NET 表的 URL 编码并转小写,使用 SHA-256,最后输出大写十六进制。

  • FR-018: 除 CheckMacValue 自身外,创建请求实际发送的全部字段 MUST 参加签名,包括 OrderResultURL、所选渠道对应的 ChoosePayment、信用卡渠道的 UnionPay=2 和 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 保存创建时实际使用的 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 支付及退款表 pos_order_omg_payment、pos_order_omg_refund MUST 通过 updatesql/sql.md 登记删除,任何可达运行链路和源码 MUST NOT 再查询或写入这些旧表。pos_store_omg 凭证表不属于该禁用范围。

  • FR-028: 所有新建表、索引或约束 SQL MUST 只追加到 updatesql/sql.md,实现过程不得执行数据库变更。

  • 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 或触发接单、出餐、完成、退款。若该订单为可配送的外送订单,MUST 在事务提交后触发可接单骑手推送。

  • 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;完整逐次回调原文只保存于 ipn_log。trade_no 非空时全局唯一,防止同一 OMG 交易被绑定到多个本地尝试。

  • FR-042: 旧 com.ruoyi.app.utils.omg 工具包及其测试 MUST 删除。可信门店凭证录入保留原表与管理流程,但联网验证 MUST 迁入新的 com.ruoyi.app.omgpay,并复用 rebuild 的签名、严格响应解析和 stage 查询边界。

  • FR-043: specs/016-omg-payment 和历史建表 SQL MUST 作为变更记录保留,但不得再作为运行实现依据;旧表删除只能追加新迁移记录,不改写或移除历史 SQL。

API Contract

Request

POST /pay/omg/create
Content-Type: application/json
token: <login-token>

{
  "orderId": "991786433092835",
  "paymentMethod": "CREDIT"
}

Success data

{
  "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": "Food order 991786433092835",
    "ItemName": "Order 991786433092835",
    "ReturnURL": "https://example.test/pay/omg/notify",
    "OrderResultURL": "https://example.test/pay/omg/result",
    "ChoosePayment": "Credit",
    "UnionPay": "2",
    "EncryptType": "1",
    "InvoiceMark": "N",
    "NeedExtraPaidInfo": "Y",
    "CheckMacValue": "<64 uppercase hexadecimal characters>"
  }
}

HashKey 和 HashIV 永远不属于响应。上例是 paymentMethod=CREDIT 的响应;paymentMethod=APPLE_PAY 时 ChoosePayment=ApplePay,且不包含 UnionPay 或 IgnorePayment。外层继续使用项目现有 AjaxResult 成功/失败封装;上例只定义 data 契约。

Existing-attempt failure

  • 外层返回项目业务失败响应。
  • 机器可识别业务状态为 PAYMENT_ATTEMPT_EXISTS。
  • 不返回旧 formFields 或新的 MerchantTradeNo。

Key Entities

OmgPaymentAttempt / pos_order_omg_attempt

表示本次重建产生的一次不可覆盖的 OMG 支付尝试。创建和最终付款回调字段如下:

Field Meaning Constraint
id 本地支付尝试主键 自增主键
dd_id pos_order.dd_id 业务订单号 非空
merchant_trade_no 本次 OMG 特店交易编号 非空、全局唯一、≤20 位英数字
store_id 创建时订单门店 非空
merchant_id 创建时门店 MerchantID 快照 非空、≤10 位
amount 创建时订单整数 TWD 金额快照 非空、>0
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 本地更新时间 非空

约束:

  • UNIQUE (merchant_trade_no);
  • UNIQUE (active_dd_id),利用 MySQL 唯一索引允许多个 NULL 的语义,为后续终态释放活跃键;
  • CREATED 只表示本地已生成并持久化表单所需事实,不能解释为 OMG 已接收或已建立订单;
  • PAID 是不可逆资金事实;FAILED 可被后续合法成功通知升级为 PAID;SUPERSEDED 表示同订单已有其他尝试成功,并非 OMG 返回的支付失败;
  • 回调保存归一化最终事实,逐次完整请求历史由 ipn_log 承载。

Existing PosStoreOmg / pos_store_omg

可信门店凭证来源。不修改其表结构、录入流程或启停流程。创建服务读取与订单 storeId 对应、已启用的 MerchantID / HashKey / HashIV,并把密钥写入本次尝试快照;回调不再依赖门店当前行。

Existing IpnLog / ipn_log

每个回调 HTTP 请求新增一行,不新增字段:ip 保存来源地址,cretim 保存接收时间,type 固定为 omg,ipn_log 保存完整原始 form-urlencoded 请求体。该流水使用独立事务,后续业务回滚不删除已经接收的通知记录。

3. Error Handling and Security

  • 创建流程必须在返回表单前完成所有订单、凭证、环境和并发校验。
  • 尝试记录与订单合法性必须处于可证明的一致事务边界;事务失败不得泄出可提交表单。
  • 数据库唯一约束是并发安全的最终防线;应用锁只能作为降低冲突的辅助机制,不能取代唯一约束。
  • PAYMENT_ATTEMPT_EXISTS 是安全终止,不是系统异常,也不能自动删除或覆盖已有行。
  • 外部网关地址和 ReturnURL 必须由服务端配置并严格校验,不接受客户端 URL,防止开放重定向或向非 OMG 主机泄露签名表单。
  • HashKey / HashIV 只在服务端签名过程中短暂使用;不得复制到新支付尝试表。
  • API 错误不返回堆栈、SQL、密钥、签名原文或内部类名。
  • 旧 Controller 下线后,/pay/omg/create 只能由新 omgpay Controller 映射;/pay/omg/notify 在下一阶段前必须没有旧处理器。
  • 日志级别必须有一致语义:正常阶段事件使用 INFO,可预期的业务拒绝使用 WARN,非预期且需要调查的异常使用 ERROR 并携带异常对象;单元测试可依赖稳定业务错误码,不依赖自然语言日志文本。
  • 日志中的 MerchantTradeNo 只显示足以关联记录的首尾片段;如果应用已有 trace/request ID,则沿用现有上下文,不自行生成新的支付追踪体系。

4. Verification Requirements

4.1 Automated tests

  • 使用 OMG 官方 AioCheckOut 示例参数和官方期望 CheckMacValue 验证完整 SHA-256 签名链路;不得用旧实现测试或其他金流向量代替官方依据。
  • 验证字段排序、HashKey/HashIV 包夹、.NET URL 编码替换、转小写、SHA-256 和大写十六进制各步骤。
  • 验证实际发送字段集合与签名输入集合完全一致;删除、增加或修改任一非 CheckMacValue 字段都会改变签名。
  • 验证回调所有实际字段、未知额外字段和空值字段均进入签名集合,只有 CheckMacValue 被排除。
  • 验证 ipn_log 在验签失败和业务事务回滚时仍独立保留,且日志写入失败不阻断付款处理。
  • 验证成功、模拟成功、失败、失败后成功、成功后失败、重复通知、取消后成功和同订单迟到第二笔成功的状态机。
  • 验证 MerchantTradeNo 仅含英数字、长度不超过 20、重复冲突不会覆盖旧行。
  • 验证台北时区与 yyyy/MM/dd HH:mm:ss 格式。
  • 验证固定字段和值、禁止字段不出现在表单、金额只取订单、文字字段安全和长度限制。
  • 验证 token、订单归属、单门店、业务状态、payType="2"、金额和门店凭证校验。
  • 验证非测试网关和不合法 ReturnURL 被拒绝。
  • 验证顺序重复和真实并发创建最多产生一条 CREATED 尝试。
  • 验证所有响应和日志均不包含登录 token、HashKey 或 HashIV;完整 CheckMacValue 与签名表单只出现在创建成功响应,不出现在错误响应或日志中。
  • 验证创建开始、校验拒绝、创建成功、重复尝试和非预期异常路径均输出规定的排障上下文与正确日志级别,同时不记录 token、凭证对象、DTO 或表单对象。
  • 通过定向代码审查验证新公开类型、签名编码、事务和唯一约束处理具有必要注释,且没有对显然代码的噪声注释。
  • 验证旧 Controller 不再注册,旧定时任务不再调度,旧补单/退款接口和取消链路调用不可达。
  • 验证删除旧 OMG 支付/退款表后,当前有效运行链路不包含对这些旧表的 Mapper/Service 调用;门店凭证 pos_store_omg 查询保持可用。

所有新增生产方法必须遵循测试先行:先运行定向测试并观察其因缺少新行为而失败,再写最小实现使其通过。

4.2 Module verification

  • 临时使用 C:\Users\qmj\.jdks\graalvm-jdk-21.0.7 设置当前命令的 JAVA_HOME 和 PATH。
  • 运行新 omgpay 测试及受旧链路下线影响的订单回归测试。
  • 运行 ruoyi-admin 及其依赖模块的 JDK 21 Maven 构建。
  • 检查 git diff,确认未改动旧凭证能力、未重写无关文件、未引入编码或换行噪音。

4.3 OMG stage acceptance

  • 使用门店测试凭证调用 POST /pay/omg/create。
  • 在当前页面将全部 formFields POST 到返回的测试 gatewayUrl。
  • 分别使用 CREDIT 与 APPLE_PAY 创建并提交表单,确认直接进入所选 OMG 测试付款流程;不得出现超商快付或 AFTEE 选择项。
  • 确认不使用 iframe 或新窗口。
  • 创建和付款结果回调按本规格完成;查询、补单、取号、退款与通用支付推送不作为本阶段完成标准,外送订单支付成功后的骑手开放推送除外。

2026-08-28 增量:外送订单支付成功后开放给骑手

  • OMG 首次成功结算且订单未取消时,读取已更新为 payStatus=1 的业务订单。
  • 仅当订单同时满足 type=0、state=0、deliveryStatus=0、afterSaleStatus=0 且未分配骑手时,事务提交后通知附近可接单骑手。
  • 本增量不得推进 state 或 deliveryStatus,不得提前通知商家备餐;商家通知由骑手实际接单后触发。
  • 重复成功回调、已支付订单和已取消订单不得重复触发骑手开放推送。

Success Criteria

Measurable Outcomes

  • SC-001: 合法首次创建请求 100% 返回固定测试端点和完整签名表单,并可进入 OMG 测试收银台。
  • SC-002: 官方 AioCheckOut 签名向量自动化测试与官方 CheckMacValue 完全一致。
  • SC-003: 实际发送的每一个非 CheckMacValue 字段均由测试证明参与签名,额外字段与空值字段不会被签名器丢弃。
  • SC-004: 对同一订单进行顺序或并发重复创建时,数据库未结束尝试数始终不超过 1,第二个 MerchantTradeNo 产生率为 0。
  • SC-005: 未授权、非法状态、非法金额、错误支付类型、非法 paymentMethod、无凭证和非测试环境请求的尝试写入数为 0。
  • SC-006: 新 OMG 创建链路中对旧支付 Controller、旧工具类和旧支付流水服务的引用数为 0;对旧支付表的 SQL 访问数为 0。
  • SC-007: 旧 OMG Controller、旧补单/退款入口和旧定时任务的可达运行入口数为 0;/pay/omg/create 只映射到新 Controller。
  • SC-008: API 响应及应用日志中的登录 token、HashKey、HashIV 泄露数为 0;错误响应和应用日志中的完整 CheckMacValue 或完整签名表单泄露数为 0。
  • SC-009: 受影响的自动化测试和 JDK 21 模块构建均以退出码 0 完成。
  • SC-010: 创建开始、创建成功、业务拒绝和非预期异常均可通过业务订单号及安全的支付尝试上下文定位;敏感信息日志泄露数为 0,非预期异常堆栈保留率为 100%。
  • SC-011: 新 omgpay 的公开类型、签名编码、事务和数据库并发边界均有必要注释,显然样板代码上的重复注释数为 0。
  • SC-012: 生产源码中旧 com.ruoyi.app.utils.omg 类定义、导入和调用数为 0;旧支付/退款表没有运行 SQL,测试源码只允许用类名或表名做退役断言。
  • SC-013: 已验证的 UNPAID + paymentType 为空 重试 100% 使用新 MerchantTradeNo;同一旧尝试并发重试时新活动尝试数不超过 1,旧表单重放次数为 0。
  • SC-014: iPhone 真机每次加载精确 /pay/omg/result 最多产生一次 result_page_detected;中间页、取消页和其他 URL 的误触发次数为 0,Android 与外部浏览器的现有返回页兜底保持可用。

Assumptions

  • pos_order.dd_id 是客户端使用的业务订单号,PosOrder.mdId 能唯一定位单门店订单的门店。
  • PosOrder.amount 是当前订单应支付的整数新台币金额。
  • pos_store_omg 的既有启用凭证查询能按 storeId 返回正确门店凭证。
  • 测试环境使用 OMG 官方公开的 AIO stage 端点;正式环境切换将在可信回调及后续流程完成后单独设计和批准。
  • 新表 SQL 由开发者手动执行;代码实现不会连接数据库执行 DDL。
  • App 退出原支付页面后会销毁 WebView;重新支付必须调用 retry 获取新表单,不要求保存或恢复原 WebView。

Official Sources