# Contracts: OMG（歐買尬/FunPoint）AIO 支付接入

**Feature**: specs/016-omg-payment/spec.md
**Date**: 2026-07-29

> **约束**：OMG 全部独立实现，零 `newebpay` 依赖（见 research.md §0）。签名统一用 `CheckMacValue`(SHA256, EncryptType=1)，无 AES、无解密。

---

## A. OMG 外部接口（我方 → 盘合）

### A1. 幕前下单 — `Cashier/AioCheckOut/V5`（US1）

- **URL**：测试 `https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5`；正式 `https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5`
- **Method/CT**：`POST`，`application/x-www-form-urlencoded`
- **方式**：后端组参 + 计算 CheckMacValue 返回 form 字段，**前端 Form Post** 跳转 OMG 收银台（不可 iframe；iOS 不可另开窗）
- **必填请求参数**：`MerchantID`、`MerchantTradeNo`(=OMG+ddId)、`MerchantTradeDate`(yyyy/MM/dd HH:mm:ss)、`PaymentType=aio`、`TotalAmount`(整数元)、`ReturnURL`、`ChoosePayment=Credit`、`CheckMacValue`、`EncryptType=1`
- **本项目固定参数**：`UnionPay=2`（隐藏银联）、`InvoiceMark=N`、`NeedExtraPaidInfo=Y`、`OrderResultURL`；新订单不得传 `ClientBackURL`、`PaymentInfoURL`、`ClientRedirectURL` 或延期缴费期限参数。
- **固定**：`InvoiceMark=N`（发票走 ezPay）；`ReturnURL ≠ OrderResultURL`；`MerchantTradeNo` 全平台唯一

### A2. 付款结果回调 — ReturnURL（盘合 → 我方，US2）

- 盘合**服务端 POST** 我方 `/pay/omg/notify`（CT `text/html`）
- **参数**：`MerchantID`、`MerchantTradeNo`、`StoreID`、`RtnCode`(**1=成功**，其余勿发货)、`RtnMsg`、`TradeNo`(OMG 交易号，须存并与 MerchantTradeNo 关联)、`TradeAmt`、`PaymentDate`、`PaymentType`、`TradeDate`、`SimulatePaid`(**1=模拟，勿发货**)、`CustomField1–4`、`CheckMacValue`
- **我方响应**：纯字符串 `1|OK`（首字符 1=成功）；未收到 5–15 分重试，当天最多 4 次
- **核销校验顺序**：CheckMacValue 验签 → `RtnCode==1` → `SimulatePaid!=1` → `TradeAmt==订单 amount` → `trade_no` 幂等

### A4. 订单查询 — `Cashier/QueryTradeInfo/V5`（凭证验证 / 补单）

- **URL**：测试 `https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5`；正式 `https://payment.funpoint.com.tw/Cashier/QueryTradeInfo/V5`
- **请求**：`MerchantID`、`MerchantTradeNo`、`TimeStamp`(3 分钟内)、`PlatformID?`、`CheckMacValue`
- **响应**(text/html，k=v)：`TradeStatus`(**0 未付/1 已付/10200095 失败**)、`TradeNo`、`TradeAmt`、`PaymentDate`、`PaymentType`、`TradeDate`、`CheckMacValue`…
- **用途**：录入凭证探测金钥 + 回调漏收时补单

### A5. 信用卡退款/取消 — `CreditDetail/DoAction`（US4）

- **URL**：正式 `https://payment.funpoint.com.tw/CreditDetail/DoAction`（**stage 不可用**）
- **请求**：`MerchantID`、`MerchantTradeNo`、`TradeNo`、`Action`(**C** 關帳/**R** 退刷/**E** 取消/**N** 放棄)、`TotalAmount`、`CheckMacValue`、`PlatformID?`
- **响应**(k=v)：`RtnCode`(**1=成功**)、`RtnMsg`
- **规则**：`已關帳→R`、`已授權→N`、`要關帳→E 再 N 或 R`；分期必须全额；自动关帳开启时避开 20:15–20:30

---

## B. 平台内部接口（暴露给前端/管理端）

### B1. 发起 OMG 支付 — `POST /pay/omg/create`（US1，@Anonymous @Auth，Header token）

- 入参：`orderid`（= ddId）
- 逻辑：校验订单归属/金额/未支付 → 查门店启用凭证 → 生成 `MerchantTradeNo` → 组参 + CheckMacValue → 落 `pos_order_omg_payment`(pay_status=0) + 更新 `pos_order.pay_type="2"`/`pay_url`
- 返回：form 字段 `{ gatewayUrl, MerchantID, MerchantTradeNo, MerchantTradeDate, PaymentType=aio, TotalAmount, ReturnURL, ChoosePayment=Credit, UnionPay=2, EncryptType=1, ItemName, CheckMacValue, ... }`，前端构建隐藏 form 自动 submit 到 `gatewayUrl`

### B2. OMG 支付结果回调 — `POST /pay/omg/notify`（US2，@Anonymous）

- 处理 A2：collectForm → IpnLog → 凭证(MerchantID) → 验签 CheckMacValue → 幂等(trade_no) → 金额校验 → RtnCode==1 && SimulatePaid!=1 → markSuccess → 更新订单(state=0, payStatus=1) + 订单日志 + 推送用户/商家/骑手 + sendAcceptRiderPush → 回纯串 `1|OK`

### B3. 支付完成返回页 — `POST /pay/omg/result`（US2，@Anonymous）

- 作为 `OrderResultURL` 接收 OMG Client POST，使用完整 DTO 严格解析并按支付尝试凭证快照验签；未知额外字段和空值全部进入 CheckMacValue。
- 验签通过后只返回 no-store HTML：从匿名只读的 `GET /pay/omg/bridge.js` 加载后端自托管 `uni.webview.1.5.8.js`；在 `UniAppJSBridgeReady` 后，iOS App-Plus 优先通过支付子 WebView 的父 uni-app 页面执行 `uni.redirectTo`，其他 App 环境使用 `uni.webView.redirectTo`，统一打开 `/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`。Bridge 不可用、非 App 环境或未完成页面交接时，回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`，手动按钮保留同一 Scheme。该入口不修改订单状态，App 必须再调 `/pay/omg/query`。

### B5. 订单查询/被动补单 — `POST /pay/omg/query`（US6 方案A，@Anonymous @Auth，Header token）

- **入参**：`orderid`（= ddId）
- **鉴权**：登录用户 + 订单本人（`token → userId == order.userId`）；`order.payType` 必须为 `"2"`
- **逻辑**：订单已 `payStatus∈{1,2}` 直接返回不查 OMG；否则复用 `OmgPay.queryTrade()`(§A4) → 按 `TradeStatus` 分支（见下），严格幂等（`markSuccess` 按 trade_no CAS，回调+补单并发不重复核销/推送）
- **复用**：`paymentService.getLatestByDdId` + `storeOmgService.getCredentialByMerchantId` + `OmgPayController.reconcileByQuery()`（与定时补单共用）→ `applyPaidResult()`（markSuccess + `handlePaymentSuccess` 推送，与 notify 同链路）
- **返回**：
  ```json
  { "code": 200, "data": { "payStatus": 1, "reconciled": true } }
  // payStatus: 0未付 / 1已付(含本次补单) / 2失败 ; reconciled: 本次是否触发了补单核销
  ```
- **TradeStatus 分支**：`1` 已付 → 金额/字段校验通过 → `applyPaidResult` 补单 + 返回已支付；`10200095` 失败 → `markFail` + 返回支付失败；`0` 或其他未知值 → 保持待支付（前端继续轮询 / 定时任务下轮再查）
- **自愈**：若流水已 `pay_status=1` 但订单未核销（跨事务中断残留），补推订单状态，避免「付了钱订单永远未支付」

### B6. 订单取消退款（US4）

- 触发点：订单取消链路（`PosOrderShOprate` 商家取消 / `UserOrderController` 用户取消）内，对 OMG 已支付订单调 A5 DoAction；或 `POST /pay/omg/refund?orderid=`（落点 tasks 定）
- 写 `pos_order_omg_refund`；成功置 `pos_order_omg_payment.pay_status=3`、`pos_order.pay_status=2`

### B7. 门店 OMG 凭证管理 — `/system/storeOmg/*`（US5，@PreAuthorize）

- `GET /list`（分页筛选）、`GET /{storeId}`、`PUT /apply/{storeId}`、`PUT /saveCredentials`（录入+QueryTradeInfo 探测验证）、`PUT /toggleEnable/{storeId}`、`PUT /reset/{storeId}`、`PUT /enabledPayments/{storeId}`
- 镜像 011 `PosStoreNewebpayController` 模式，但**独立 Controller/Service/实体/表**

### B8. 漏单定时补单 — `OmgReconcileTask`（US6 方案B，@Scheduled + Redisson）

- **触发**：`@Scheduled(fixedDelay)` 默认每 3 分钟一轮（`omg.reconcile.fixed-delay-ms`），上一轮跑完才开始计时，启动延迟 60s
- **扫描**：`paymentService.scanLeakOrders(windowStart, graceCutoff, batchSize)` → `pos_order_omg_payment` 中 `pay_status=0` 且 `create_time` 落在 `[now-windowHours, now-graceMinutes]`、每订单取最新一笔、`INNER JOIN pos_order` 排除已取消(`state≠4`)
- **补单**：逐笔调 `OmgPayController.reconcileByQuery(ddId,"scheduled")`（与 §B5 被动补单同一核销链路）
- **补单窗口**：按当前配置扫描仍待支付的有效信用卡尝试；`grace-minutes` 给回调/OMG重试留送达时间，窗口外停止扫描避免无限轮询。
- **多实例并发保护**：Redisson 分布式锁 `lock:omg:reconcile`（`tryLock` 拿不到即让出），保证同一轮只一个节点执行；幂等能兜底，锁减少无效调用
- **配置**：`application.yml` → `omg.reconcile.{fixed-delay-ms, initial-delay-ms, window-hours, grace-minutes, batch-size, lock-wait-seconds, lock-lease-seconds}`

---

## C. 代码表与枚举（OMG reference 02–05，2026-07-29 补读）

### C1. ChoosePayment（请求参数，US1）

本期固定 `Credit`，并传 `UnionPay=2` 隐藏银联，只保留普通信用卡与 Apple Pay。OMG 官方将 Apple Pay 归在 `Credit` 下；回覆 PaymentType 同为 `Credit_CreditCard`，仅凭 PaymentType 无法区分两者（本期不区分）。

### C2. 回覆 PaymentType（回调 `PaymentType`，落 `pos_order_omg_payment.pay_type`）

| PaymentType | 名称 | 即时/延期 |
|---|---|---|
| `Credit_CreditCard` | 信用卡（含 Apple Pay；请求以 `UnionPay=2` 隐藏银联） | 即时 |

本项目当前只接受这一种回覆类型；开发测试阶段没有延期支付历史交易需要兼容。

### C3. RtnCode（回调 / 退款响应）

`1` = 成功；其余均为失败。完整代码表文档为**图片**且持续新增，须到「歐買尬廠商後台 → 系統開發管理 → 交易狀態代碼查詢」查全。
**实现策略**：`RtnCode==1` 视成功（核销 / 退款成功）；非 1 一律失败，记录 `rtn_code`+`rtn_msg` 供诊断，**不硬编码错误码分支**。

### C4. URLEncode（CheckMacValue 计算，须完全照此 · reference 05）

.NET 风格 URLEncode。**Java 实现**：`URLEncoder.encode(s, UTF_8)`（空格已→`+`）后，照 PHP 范例做替换以对齐 .NET，再转小写、SHA256、hex 大写：
- 不编码（字面）：`-` `_` `.` `!` `*` `(` `)`；`~`→`%7e`（两侧同）。
- 空格→`+`（.NET/Java 表单编码一致；标准 URLEncode 是 `%20`，勿混用）。
- 其余特殊符 `@ # $ % ^ & = + ; ? / \ > < [ ] { } : ' " , |` 均为 `%XX`；中文按 UTF-8 `%XX`。
- 替换集（防御性，照 PHP 范例）：`%2d→-`、`%5f→_`、`%2e→.`、`%21→!`、`%2a→*`、`%28→(`、`%29→)`（Java `URLEncoder` 本就不编 `-_.`，`!*()` 需替换回）。

## 签名约定（所有请求/回调通用）

`CheckMacValue`(EncryptType=1, SHA256)：
去 CheckMacValue → 参数按 key 字母序 → `k=v&...` → 包夹 `HashKey={k}&..&HashIV={iv}` → .NET 风格 URL 编码(`-_.!*()` 不编码) → 转小写 → SHA256 → hex 大写。校验同理重算比对。实现：`OmgCheckMacValue`（独立，不复用 `NewebPayEncryptUtil`）。
