offline-merchant-scan-design.md 14 KB

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 官方文档为准:

直接影响实现的约束如下:

  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 发起扫码付款

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 查询本地支付状态

GET /system/orderShOprate/linePay/offline/status?ddId=<Foodie业务订单号>
Header: token

查询接口先验证商家订单归属,再返回稳定的本地结果:

AUTH_REQUIRED  客户需在 LINE Pay 完成认证
PROCESSING     网关结果确认中,禁止重新扫码扣款
PAID           已核实支付成功
CANCELLED      客户取消
FAILED         明确失败,需要客户生成新的 My Code
MANUAL_REVIEW  结果长期不明确,需要人工核对

响应不返回 oneTimeKey、Channel Secret、HMAC、网关原始请求体或内部异常。

7. Offline 付款请求

LinePayClient 增加独立方法,不能复用 Online request/check/refund 的 URI:

POST /v4/payments/oneTimeKeys/pay
GET  /v4/payments/orders/{lineOrderId}/check
POST /v4/payments/orders/{lineOrderId}/refund

首版付款请求仅发送自动请款所需字段,不发送 redirect URL、Capture/Void 或设备请求头:

{
  "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 流水复用现有基础状态:

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:存在相同或重复请求的可能,不直接判失败,转只读状态查询;任何未识别 Pay/Check 返回码也按未知结果处理,不能按失败释放订单。
  • 网络超时、响应丢失、无法解析响应、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,记录真实交易号和 captured_amount,并在同一事务内以实际扣款额创建唯一全额退款意图;不得执行正常订单已支付/履约副作用,也不得允许再次扫码,退款结果仍不明确时进入人工核对。

9. 并发和幂等

  • 同一订单的 Online 和 Offline 尝试共用现有订单级支付创建锁和 active_dd_id 唯一门禁。
  • 已有任一模式的活跃尝试时,不发起另一个模式的新请求。
  • 重复提交 Offline 接口时,若已经存在 Offline 活跃尝试,只返回其当前状态;新传入的 oneTimeKey 不发送、不记录。
  • 创建尝试发生唯一键冲突时直接复用数据库中的活跃尝试;所有状态 CAS 失败后重读数据库状态,不以过期内存对象继续网关副作用。
  • 只有 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 完成真实端到端验证;若缺少商户权限或凭证,明确记录为未执行项。