Pārlūkot izejas kodu

补充 LINE Pay 商家扫码支付规格

依据 LINE Pay Offline API v4 官方文档,记录商家订单来源、支付模式、扫码接口、状态恢复、退款分派及 oneTimeKey 安全边界。
qmj 1 mēnesi atpakaļ
vecāks
revīzija
d5735c6c71

+ 256 - 0
specs/019-line-pay/offline-merchant-scan-design.md

@@ -0,0 +1,256 @@
+# LINE Pay Offline API v4 商家扫码支付设计
+
+**状态**:已确认
+
+**确认日期**:2026-08-18
+
+**所属规格**:`019-line-pay`
+**实现范围**:仅 `foodie_server` 后端
+
+## 1. 目标和已确认边界
+
+在现有 LINE Pay Online API v4 支付不变的前提下,增加实体门店“商家扫描客户 LINE Pay 我的条码(My Code)”支付能力。
+
+- `payType="3"` 继续统一表示 LINE Pay,不为扫码支付新增支付渠道编号。
+- Online 与 Offline 通过支付尝试的 `payment_mode` 区分并长期并存。
+- 只有 `/system/orderShOprate/createOrder` 创建的新商家订单可以发起 Offline 扫码支付。
+- 商家订单只允许一个门店包;后端仍须校验,不能信任客户端传入的 `shId/mdId`。
+- 商家端是 uni-app。本期不修改 App、`foodie-store` 或 `foodie-admin-vue`,只定义后端接口契约。
+- App 只负责扫码并把 `oneTimeKey` 传给后端;金额、币种、商品、门店和 LINE `orderId` 均由后端生成或读取。
+- 台湾使用默认自动请款。本期不实现分开请款、Capture 或 Void。
+- 继续使用现有门店级 `pos_store_line_pay` 凭证及其已经批准的保存策略,本增量不变更 Channel Secret 的存储和回显规则。
+
+## 2. 官方文档核对结论
+
+本设计以 2026-08-18 可访问的 LINE Pay 官方文档为准:
+
+- [实体付款总览](https://developers-pay.line.me/zh/offline)
+- [提前准备](https://developers-pay.line.me/zh/offline/prerequisites)
+- [执行付款](https://developers-pay.line.me/zh/offline/implement-payment)
+- [授权与请款分开](https://developers-pay.line.me/zh/offline/implement-capture-separated-payment)
+- [查询付款明细](https://developers-pay.line.me/zh/offline/retrieve-payment-details)
+- [退款处理](https://developers-pay.line.me/zh/offline/handle-refund)
+- [LINE POINTS 处理](https://developers-pay.line.me/zh/offline/handle-point-payment-separately)
+- [Offline API v4 总览与错误码](https://developers-pay.line.me/zh/offline-api-v4)
+- [付款请求](https://developers-pay.line.me/zh/offline-api-v4/request-payment)
+- [查询付款状态](https://developers-pay.line.me/zh/offline-api-v4/check-payment-status)
+- [查询授权信息](https://developers-pay.line.me/zh/offline-api-v4/retrieve-confirmation-information)
+- [请款](https://developers-pay.line.me/zh/offline-api-v4/capture)
+- [取消授权](https://developers-pay.line.me/zh/offline-api-v4/void)
+- [查询付款明细 API](https://developers-pay.line.me/zh/offline-api-v4/retrieve-payment-details)
+- [退款 API](https://developers-pay.line.me/zh/offline-api-v4/refund)
+- [Sandbox](https://developers-pay.line.me/zh/sandbox)
+- [FAQ](https://developers-pay.line.me/zh/faq)
+- [API 变更日志](https://developers-pay.line.me/zh/api-change-log)
+- [专业术语](https://developers-pay.line.me/zh/glossary)
+
+直接影响实现的约束如下:
+
+1. Offline API v4 使用与 Online API v4 相同的 HMAC-SHA256 请求认证;v4 不需要服务器 IP allowlist。
+2. 台湾 `oneTimeKey` 长度为 18,生成后五分钟内有效。
+3. `POST /v4/payments/oneTimeKeys/pay` 的 Read Timeout 至少为 40 秒。
+4. 付款请求超时或没有收到响应时,使用 `GET /v4/payments/orders/{orderId}/check` 查询;该接口 Read Timeout 至少为 20 秒,禁止直接重复付款请求。
+5. 状态查询的 `info.status` 为 `AUTH_READY`、`COMPLETE`、`CANCEL` 或 `FAIL`。
+6. `AUTH_READY` 以及结果码 `1169` 表明客户仍可能需要在 LINE Pay 选择付款方式并完成密码认证,扫码本身不等于支付成功。
+7. 台湾默认自动请款;只有事先向 LINE Pay 申请并配置分开请款时才使用 Capture/Void。
+8. 成功后必须校验 `info.payInfo[].amount` 合计与请求金额一致;不一致必须退款。
+9. Offline 全额退款使用 `POST /v4/payments/orders/{orderId}/refund`,省略 `refundAmount`。
+10. API 业务结果通过 `returnCode` 判断,不能把 HTTP 200 当成成功。
+11. `transactionId` 是 19 位整数,沿用现有字符串存储和 JSON 字符串输出。
+12. v4 返回的 `paymentProvider` 可能为 `TSP` 或 `EPI`,沿用现有字段保存原值。
+13. Sandbox 不能使用真实 LINE App 生成的 My Code,必须使用官方 Sandbox My Code 生成器;Sandbox 不能测试 iPASS Money 条码。
+14. `X-LINE-MerchantDeviceProfileId` 和 `X-LINE-MerchantDeviceType` 为成对出现的选填请求头,本期不接收或发送设备信息。
+
+## 3. 方案选择
+
+采用“扩展现有支付尝试”方案:在 `pos_order_line_payment` 增加模式字段,复用现有凭证、审计、支付事实、退款事实、行租约和恢复任务。
+
+未采用以下方案:
+
+- 新建 Offline 专用支付表:隔离清晰,但会复制凭证、状态机、退款、审计和恢复逻辑。
+- 不增加模式字段:改动更少,但查询、退款和恢复无法可靠判断应调用 Online 还是 Offline 端点。
+
+## 4. 数据模型
+
+### 4.1 `pos_order.order_source`
+
+新增 `order_source VARCHAR(16) NOT NULL DEFAULT 'USER'`:
+
+- `USER`:用户端或历史订单,不允许 Offline 扫码支付。
+- `MERCHANT`:由商家下单接口创建,允许在通过其他门禁后发起 Offline 扫码支付。
+
+`/system/orderShOprate/createOrder` 必须显式写入 `MERCHANT`。默认值使历史订单保持不可扫码,不做历史来源推断或回填。
+
+### 4.2 `pos_order_line_payment.payment_mode`
+
+新增 `payment_mode VARCHAR(16) NOT NULL DEFAULT 'ONLINE'`:
+
+- `ONLINE`:现有 Online API v4 流水。
+- `OFFLINE`:本次新增的 My Code 扫码流水。
+
+所有现有行通过默认值保持 `ONLINE`。新建 Online/Offline 尝试时必须显式写入模式。退款、查询和恢复按模式选择端点,不根据是否存在 `paymentUrl` 等可变字段猜测。
+
+不保存 `oneTimeKey`,也不新增设备信息字段。
+
+## 5. 商家订单门禁
+
+商家下单接口在创建订单前必须一次性完成以下校验:
+
+1. token 对应有效商家用户;不能仅凭客户端调用了商家 URL 判断身份。
+2. `items` 非空且恰好一个门店包,所有门店字段一致。
+3. 根据项目既有商家归属规则校验 `shId/mdId`,禁止商家替其他门店创建可扫码订单。
+4. 商品门店、订单金额和 `paymentMethod` 通过现有业务规则。
+5. 创建出的 `PosOrder` 显式写入 `orderSource=MERCHANT`。
+
+扫码接口还必须验证:订单来源为 `MERCHANT`、属于当前商家、`payType="3"`、未支付、未取消、未完成、金额大于零、只有一个子订单/门店,并且门店存在已启用 LINE Pay 凭证。
+
+## 6. 后端接口契约
+
+### 6.1 发起扫码付款
+
+```text
+POST /system/orderShOprate/linePay/offline/pay
+Header: token
+Content-Type: application/json
+
+{
+  "ddId": "Foodie 业务订单号",
+  "oneTimeKey": "台湾 18 位 My Code"
+}
+```
+
+- Controller 使用明确 DTO、`@RequestBody` 和 `@RequestHeader String token`,不使用 Map 或 Bean Validation。
+- `ddId` 用于定位 Foodie 订单;提交给 LINE 的 `orderId` 是后端为本次支付尝试生成的永久唯一 `line_order_id`,两者不得混用。
+- `oneTimeKey` 只允许本次方法调用链在内存中使用,接口返回前不持久化。
+
+### 6.2 查询本地支付状态
+
+```text
+GET /system/orderShOprate/linePay/offline/status?ddId=<Foodie业务订单号>
+Header: token
+```
+
+查询接口先验证商家订单归属,再返回稳定的本地结果:
+
+```text
+AUTH_REQUIRED  客户需在 LINE Pay 完成认证
+PROCESSING     网关结果确认中,禁止重新扫码扣款
+PAID           已核实支付成功
+CANCELLED      客户取消
+FAILED         明确失败,需要客户生成新的 My Code
+MANUAL_REVIEW  结果长期不明确,需要人工核对
+```
+
+响应不返回 `oneTimeKey`、Channel Secret、HMAC、网关原始请求体或内部异常。
+
+## 7. Offline 付款请求
+
+`LinePayClient` 增加独立方法,不能复用 Online `request/check/refund` 的 URI:
+
+```text
+POST /v4/payments/oneTimeKeys/pay
+GET  /v4/payments/orders/{lineOrderId}/check
+POST /v4/payments/orders/{lineOrderId}/refund
+```
+
+首版付款请求仅发送自动请款所需字段,不发送 redirect URL、Capture/Void 或设备请求头:
+
+```json
+{
+  "amount": 100,
+  "currency": "TWD",
+  "oneTimeKey": "<仅发送给LINE,不记录>",
+  "orderId": "后端生成的lineOrderId",
+  "packages": [
+    {
+      "id": "单门店包标识",
+      "amount": 100,
+      "products": [
+        {
+          "name": "服务端生成的订单商品摘要",
+          "quantity": 1,
+          "price": 100
+        }
+      ]
+    }
+  ]
+}
+```
+
+`amount` 必须等于 package 金额合计;package 金额必须等于 product 的 `quantity × price` 合计。签名使用实际发送的同一份 UTF-8 JSON 字符串。
+
+## 8. 状态机和错误处理
+
+Offline 流水复用现有基础状态:
+
+```text
+REQUESTING
+REQUEST_UNKNOWN
+WAITING_AUTH
+PAID
+CANCELLED_OR_EXPIRED
+FAILED
+AMOUNT_MISMATCH
+MANUAL_REVIEW
+```
+
+- `0000`:核对 `orderId`、`transactionId`、`payInfo` 金额合计和 `paymentProvider`;全部一致后落 `PAID` 并且只执行一次订单支付事实与后续副作用。币种固定取本地请求事实 `TWD`,不要求付款响应返回该字段。
+- `1145`、`1169`:进入 `WAITING_AUTH`,本地返回 `AUTH_REQUIRED/PROCESSING`,后续按 `lineOrderId` 查询。
+- `1152`、`1172`、`1198`:存在相同或重复请求的可能,不直接判失败,转只读状态查询。
+- 网络超时、响应丢失、无法解析响应、`1199`、`190X`、`9000`:进入 `REQUEST_UNKNOWN`,禁止重新调用付款接口,只能状态查询。
+- `1133`:My Code 无效或过期,明确失败并提示重新生成 My Code。
+- 余额不足、用户/信用卡不可用、金额/限额等明确业务拒绝:进入 `FAILED`。
+- 查询 `AUTH_READY`:保持 `WAITING_AUTH`。
+- 查询 `COMPLETE`:核对交易号、订单号和 `payInfo` 金额合计后落支付事实;查询响应未返回的币种继续以本地请求事实 `TWD` 为准。
+- 查询 `CANCEL`:进入 `CANCELLED_OR_EXPIRED` 并释放活跃尝试。
+- 查询 `FAIL`:进入 `FAILED` 并释放活跃尝试。
+- 查询暂时 `1150` 或未取得终态:在恢复截止时间前继续只读查询;到期进入 `MANUAL_REVIEW`,不自动释放订单占用。
+
+若 `payInfo` 合计与订单金额不一致,支付行进入 `AMOUNT_MISMATCH`,记录真实交易号并立即创建唯一全额退款意图;不得执行正常订单已支付/履约副作用,也不得允许再次扫码,退款结果仍不明确时进入人工核对。
+
+## 9. 并发和幂等
+
+- 同一订单的 Online 和 Offline 尝试共用现有订单级支付创建锁和 `active_dd_id` 唯一门禁。
+- 已有任一模式的活跃尝试时,不发起另一个模式的新请求。
+- 重复提交 Offline 接口时,若已经存在 Offline 活跃尝试,只返回其当前状态;新传入的 `oneTimeKey` 不发送、不记录。
+- 只有 `CANCELLED_OR_EXPIRED` 或明确 `FAILED` 释放后,才能使用客户新生成的 My Code 创建新尝试。
+- `PAID`、`REQUEST_UNKNOWN`、`WAITING_AUTH`、`AMOUNT_MISMATCH` 和 `MANUAL_REVIEW` 持续阻断新支付。
+- 支付事实、退款意图、订单 `payStatus` 和通知/结算副作用均使用现有 CAS/唯一约束保证至多一次。
+
+## 10. 退款和取消订单
+
+- Online 流水继续按 `transactionId` 调用 `/v4/payments/{transactionId}/refund`。
+- Offline 流水按 `lineOrderId` 调用 `/v4/payments/orders/{lineOrderId}/refund`。
+- 两种模式的全额退款请求均省略 `refundAmount`,但 URI 必须根据 `payment_mode` 分派。
+- 退款响应超时或结果未知时只调用 Retrieve 检查 `refundList`,不得盲目重复 Refund。
+- 订单取消与迟到支付竞态继续复用现有“记录真实付款后创建唯一退款意图”规则。
+
+## 11. 日志和敏感数据
+
+- `oneTimeKey` 不得进入数据库、普通日志、异常文本、Controller 响应或 `payment_gateway_log.payload`。
+- Offline REQUEST 审计日志只保存脱敏后的业务摘要;若记录 JSON,`oneTimeKey` 固定替换为 `<redacted>`。
+- 网关 `returnCode`、`returnMessage`、`lineOrderId`、字符串 `transactionId`、`paymentProvider` 和耗时可以记录。
+- 本增量不改变现有规格已经批准的 Channel Secret 保存和权限回显策略;真实 Secret 仍不得写入源码、测试夹具或支付交互日志。
+
+## 12. Sandbox 和生产前置条件
+
+- Sandbox 使用 `https://sandbox-api-pay.line.me` 和官方台湾 My Code 生成器。
+- 自动化测试不得把真实 LINE App My Code 用于 Sandbox,也不把 Sandbox iPASS Money 作为验收项。
+- EPI 测试使用官方变更日志列出的 My Code preset,并验证 `paymentProvider` 原值。
+- 生产启用前必须确认对应 LINE Pay 商户已取得 Channel ID/Secret 且具备 Offline API 商户权限;只有固定收款码、无法取得 API credentials 的商户不满足接入条件。
+
+## 13. 验证范围
+
+书面规格审批后,实施计划至少覆盖:
+
+1. 商家下单身份、归属、单门店和订单来源测试。
+2. Entity、Mapper、SQL 默认值及 Online 历史数据兼容测试。
+3. Offline HMAC 精确请求体、40 秒付款超时和 20 秒查询/退款超时测试。
+4. `oneTimeKey` 18 位校验、禁止落库和全链路日志脱敏测试。
+5. `0000/1145/1169/1133/重复请求/未知结果` 映射测试。
+6. `AUTH_READY/COMPLETE/CANCEL/FAIL` 状态查询测试。
+7. 超时后只查询、不重复付款请求的测试。
+8. 金额合计一致、金额不一致自动退款且不触发履约的测试。
+9. Online/Offline 活跃尝试互斥和重复扫码并发测试。
+10. Offline 按 `lineOrderId` 退款、Online 仍按 `transactionId` 退款的回归测试。
+11. 使用官方 Sandbox My Code 完成真实端到端验证;若缺少商户权限或凭证,明确记录为未执行项。

+ 52 - 2
specs/019-line-pay/spec.md

@@ -1,10 +1,12 @@
-# Feature Specification: LINE Pay Online API v4 支付接入
+# Feature Specification: LINE Pay Online / Offline API v4 支付接入
 
 **Feature Branch**: `019-line-pay`
 
 **Created**: 2026-08-12
 
-**Status**: Approved — 2026-08-12
+**Updated**: 2026-08-18(增加商家扫描客户 My Code 的 Offline API v4 后端能力)
+
+**Status**: Approved — 2026-08-12;Offline 增量已确认 — 2026-08-18
 
 **Input**: 在餐饮订单中新增 LINE Pay 直连支付,使用 `payType="3"`,支持门店级凭证、支付确认、全额退款、主动状态查询、平台管理和 App 回跳。
 
@@ -583,3 +585,51 @@ Sandbox 官方不支持 App payment URL,也不能模拟 EPI;以下必须在
 - [基础付款流程](https://developers-pay.line.me/zh/online/implement-basic-payment)
 - [Sandbox / FAQ](https://developers-pay.line.me/zh/faq)
 - [API 变更日志](https://developers-pay.line.me/zh/api-change-log)
+
+## 16. 2026-08-18 Offline 商家扫码支付增量规格
+
+本节扩展现有 Online API v4 功能。详细设计、官方文档核对结论、状态映射和测试边界见 [`offline-merchant-scan-design.md`](offline-merchant-scan-design.md)。本节与前文冲突时,仅在 Offline 商家扫码支付范围内以本节为准;现有 Online 流程保持不变。
+
+### 16.1 已确认决策
+
+- **FR-OFF-001**:系统 MUST 只修改 `foodie_server` 后端;商家端为 uni-app,本期不修改 App、`foodie-store` 或 `foodie-admin-vue`。
+- **FR-OFF-002**:`payType="3"` MUST 继续表示 LINE Pay;Online 与 Offline MUST 通过 `pos_order_line_payment.payment_mode` 的 `ONLINE/OFFLINE` 区分并存。
+- **FR-OFF-003**:只有 `/system/orderShOprate/createOrder` 创建且持久化为 `order_source="MERCHANT"` 的新订单可以发起 Offline 扫码支付;历史订单及用户端订单默认 `USER`,不得推断或回填来源。
+- **FR-OFF-004**:商家下单 MUST 验证 token 用户为商家、订单门店属于该商家,并且 `items` 仅包含一个门店包;扫码支付时 MUST 再次校验来源、归属、单门店、订单状态、`payType`、金额和门店凭证。
+- **FR-OFF-005**:App 只提交 `ddId` 和台湾 18 位 `oneTimeKey`;金额、币种、商品、门店、凭证及 LINE `orderId` MUST 取自服务端事实。
+- **FR-OFF-006**:系统 MUST 调用 Offline API v4 `POST /v4/payments/oneTimeKeys/pay` 并使用台湾默认自动请款;本期 MUST NOT 实现分开请款、Capture、Void、redirect URL 或设备请求头。
+- **FR-OFF-007**:付款请求 Read Timeout MUST 不少于 40 秒;状态查询和退款 Read Timeout MUST 不少于 20 秒。
+- **FR-OFF-008**:付款请求超时、响应丢失或结果不明确后 MUST 按 `lineOrderId` 调用 `GET /v4/payments/orders/{orderId}/check`,MUST NOT 重复提交付款请求或重用 `oneTimeKey`。
+- **FR-OFF-009**:系统 MUST 将 `AUTH_READY/COMPLETE/CANCEL/FAIL` 分别映射为等待认证、支付完成、客户取消和支付失败;`1169` MUST 被视为客户仍需选择付款方式并完成认证,不得把扫码动作直接视为支付成功。
+- **FR-OFF-010**:只有 `returnCode="0000"` 或状态查询 `COMPLETE` 且订单号、交易号、`payInfo` 金额合计和 `paymentProvider` 通过核对后,系统才能写入支付事实;币种固定使用本地请求事实 `TWD`,不得要求付款/状态响应返回未公开保证的币种字段。
+- **FR-OFF-011**:若 `payInfo` 合计与订单金额不一致,系统 MUST 记录真实交易、阻止正常履约并创建唯一全额退款意图;未知退款结果继续只读核实,不允许再次扫码。
+- **FR-OFF-012**:Online 和 Offline MUST 共用订单级支付锁和活跃尝试唯一门禁。任一模式存在活跃尝试时,另一模式不得发起;重复 Offline 请求只返回已有状态,不发送新 `oneTimeKey`。
+- **FR-OFF-013**:Offline 全额退款 MUST 使用 `POST /v4/payments/orders/{lineOrderId}/refund`;Online 退款继续使用现有按 `transactionId` 的端点,分派依据只能是持久化的 `payment_mode`。
+- **FR-OFF-014**:`oneTimeKey` MUST NOT 落库、写入普通日志、异常、响应或网关审计原文;Offline REQUEST 审计只能保存移除该字段或替换为 `<redacted>` 的摘要。
+- **FR-OFF-015**:本增量 MUST 复用现有门店级 LINE Pay 凭证和已经批准的 Channel Secret 保存/回显策略,不新增凭证表或 Offline 开关;生产启用前必须由业务方确认对应商户具备 Offline API 权限。
+- **FR-OFF-016**:所有 DDL 只追加到 `updatesql/sql.md`,不得由实现或测试直接执行。
+
+### 16.2 接口验收
+
+```text
+POST /system/orderShOprate/linePay/offline/pay
+Header: token
+Body: { "ddId": "...", "oneTimeKey": "..." }
+
+GET /system/orderShOprate/linePay/offline/status?ddId=...
+Header: token
+```
+
+接口返回的规范状态为 `AUTH_REQUIRED/PROCESSING/PAID/CANCELLED/FAILED/MANUAL_REVIEW`。Controller 必须使用明确 DTO、`@RequestBody`、`@RequestParam` 和 `@RequestHeader String token`,不得使用 Map 入参或 Bean Validation。
+
+### 16.3 验收场景
+
+1. **Given** 当前商家拥有一笔新建的单门店 `MERCHANT` 订单,**When** 提交有效 My Code,**Then** 后端生成唯一 `lineOrderId`、创建 `OFFLINE` 尝试并按服务端金额请求 LINE Pay。
+2. **Given** 用户端订单、历史订单、跨商家门店或多门店输入,**When** 请求扫码支付,**Then** 在调用 LINE Pay 前拒绝。
+3. **Given** LINE 返回 `AUTH_READY` 或 `1169`,**When** 商家查询,**Then** 返回 `AUTH_REQUIRED/PROCESSING` 并阻止第二次扫码。
+4. **Given** LINE 返回 `COMPLETE` 且所有资金字段匹配,**When** 状态落库,**Then** 订单、结算和通知副作用至多执行一次。
+5. **Given** 付款请求达到超时或返回不确定结果,**When** 恢复任务运行,**Then** 只按 `lineOrderId` 查询,不再次请求付款。
+6. **Given** LINE 返回 `CANCEL/FAIL`,**When** 状态落库,**Then** 释放活跃尝试,客户必须生成新的 My Code 才能再次支付。
+7. **Given** 支付金额不一致,**When** 后端处理,**Then** 不触发正常履约,创建唯一全额退款意图并保持订单阻断直到资金结果明确。
+8. **Given** 已支付 Offline 流水需要退款,**When** 取消订单或平台退款流程处理,**Then** 使用 `lineOrderId` 调用 Offline Refund;Online 回归仍使用 `transactionId`。
+9. **Given** 任意日志或异常路径,**When** 请求结束,**Then** 数据库和日志中均不存在原始 `oneTimeKey`。