# LINE Pay Research and Decisions

**Date**: 2026-08-12；Offline 增量更新 2026-08-18
**API baseline**: LINE Pay Online API v4 + Offline API v4（台湾）

## R1. LINE 请求字段与内部 payType 分离

**Decision**: 项目订单 `payType="3"` 仅表示 LINE Pay 渠道，不发送给 LINE。普通付款请求发送 `capture=true`，省略当前公开 v4 Request 页面未明确保证的 `payType/confirmUrlType/appPackageName`，使用默认浏览器回跳。

**Rationale**: 避免依赖当前公开契约未列出的枚举和字段；内部枚举始终与 LINE 请求字段无关。

**Rejected**: 把数字 `3` 写入 LINE 请求；这会违反官方契约。

**Source**: https://developers-pay.line.me/zh/online-api-v4/request-payment

## R2. 凭证验证与自动启用

**Decision**: LINE 没有专门的无副作用 credential validation API。保存前使用候选 Channel ID/Secret 对随机、不存在的 `orderId` 调 Retrieve；Sandbox 接受签名且返回预期 `1150`（或结构完整的 `0000`）视为“凭证已被环境接受”，随后原子创建并切换为当前启用版本。网络异常、`9000` 或认证错误不切换当前版本。

**Rationale**: `1150` 的官方含义只是无交易记录，不能证明币种、门店归属或真实付款能力。用户已批准把该探测作为自动启用门禁，Sandbox 端到端付款仍为上线验收项。

**Rejected**: 保存后不校验；使用 Request 创建真实付款做凭证探测（有副作用）。

**Source**: https://developers-pay.line.me/zh/online-api-v4/retrieve-payment-details

## R3. 凭证采用不可变版本

**Decision**: 每次换 Channel ID 或 Secret 且验证成功后新建版本；旧版本不覆盖并由支付行保存 `credential_id`。相同值重复提交采用幂等返回，不额外生成版本。每门店仅一个 `current_store_id=store_id` 的当前版本。

旧版本仍优先用于交易恢复；若其返回明确凭证鉴权失败，系统只允许尝试同门店、同环境且 Channel ID 完全相同的当前版本，并必须先用 Retrieve 严格匹配 transactionId、orderId、currency、amount 与退款证据。只读证据成立后，本轮才可使用当前版本继续相应支付事实恢复或退款；不同 Channel、不同环境或证据不完整一律保持 UNKNOWN/人工核对。

**Rationale**: Confirm/Retrieve/Refund 必须知道付款发起时的渠道身份；版本关系也提供完整审计。若 LINE 后台使旧 Secret 立即失效，原凭证调用失败后只允许在相同 Channel ID 的当前版本上先做只读 Retrieve 证明交易归属，再使用当前版本继续恢复；不同 Channel ID 不自动替代。

**Rejected**: 一店一行直接覆盖；在每笔支付快照 Secret（重复敏感数据且难轮换）。

## R4. 支付尝试为追加式账本

**Decision**: `pos_order_line_payment` 对订单是 1:N。重复点击复用唯一活跃行；只有网关明确取消/过期/失败后才把 `active_dd_id` 置 NULL，重新支付插入新行。PAID、UNKNOWN、MANUAL_REVIEW 都继续阻断新尝试。

**Rationale**: LINE 回跳和网关恢复依赖旧 `line_order_id/transaction_id`；覆盖会导致迟到事件写到新交易并让退款不可追溯。

**Rejected**: 每订单固定一行覆盖失败/取消尝试；仅 `UNIQUE(dd_id,is_active)` 且历史写 0（会限制只能一条 inactive）。

## R5. 浏览器回跳快速返回

**Decision**: `/pay/line/confirm` 只持久化回跳事实并唤醒异步处理，快速返回自包含 HTML；页面尝试固定 App Scheme 并提供点击兜底，App 通过 `/query` 读取最终状态。

**Rationale**: 官方重定向页面的 confirmURL read timeout 约 20 秒，而 Confirm read timeout 要至少 40 秒，同步等待存在确定的超时冲突。

**Rejected**: 回跳 Controller 内同步 Confirm 后才输出 HTML；独立部署静态安全页。

**Sources**: https://developers-pay.line.me/zh/online-api-v4/merchant/redirection-pages/ ; https://developers-pay.line.me/zh/online-api-v4/confirm-payment

## R6. Retrieve 是支付事实来源，Check 是恢复提示

**Decision**: 恢复先 Retrieve。只有 `returnCode=0000` 且 `info[]` 唯一匹配 orderId/transactionId/currency、`transactionType=PAYMENT`、`payInfo` 金额合计才判已支付；不依赖当前公开契约未保证的 `payStatus`。Retrieve `1150` 时再 Check：`0000` 等待、`0110` 通过本地门禁后 Confirm、`0121/0122` 在再次排除付款事实后终止、`0123` 回 Retrieve。

**Rationale**: Check `0123` 仍要求查询实际付款详情；Retrieve `0000` 仅表示查询成功，不能单独表示已 Capture。

**Rejected**: 把 Check `0000/0123` 或 Retrieve `0000` 直接映射为已支付。

**Sources**: https://developers-pay.line.me/zh/online-api-v4/check-payment-request-status ; https://developers-pay.line.me/zh/online-api-v4/retrieve-payment-details

## R7. 全额退款单独建模

**Decision**: 每笔 `PAID` payment 最多一条退款行；全额退款调用省略 `refundAmount`。Refund `0000` 保存 `refundTransactionId`；未知结果只通过 Retrieve 的 `refundList` 恢复。支付行永不改成 REFUNDED。

**Rationale**: 官方用省略 `refundAmount` 表达全额退款；付款和退款是两个独立资金事实。

**Rejected**: 发送等于订单金额的 `refundAmount` 并假设一定等价；Refund UNKNOWN 直接重发。

**Source**: https://developers-pay.line.me/zh/online-api-v4/refund

## R8. 外部副作用必须有 durable intent

**Decision**: Request 前先提交 `REQUESTING + line_order_id`；Confirm/Refund 前先 CAS 到 PROCESSING 状态；HTTP 后另事务写事实。超时进入 UNKNOWN，不能回退为可直接重复副作用的状态。

**Rationale**: 数据库事务不能回滚 LINE 已执行的网络操作，响应丢失必须能用稳定键查询恢复。

**Rejected**: 把数据库写和 HTTP 包在同一个 `@Transactional` 方法中。

## R9. 跨渠道创建门禁

**Decision**: LINE 与 OMG create 共用 `pay:create:<ddId>` watchdog 锁和 `pos_order` 条件更新。LINE 还必须存在匹配支付行，不能仅凭历史 `payType=3` 识别 LINE。

**Rationale**: LINE 表上的唯一活跃键只能阻止 LINE 对 LINE，无法阻止 OMG 与 LINE 并发；历史 `payType=3` 可能属于已下线的 ZaloPay。

**Rejected**: 仅在 LINE 表加唯一索引；迁移或修改 OMG 表。

## R10. 安全中间页与 Sandbox 限制

**Decision**: Controller 返回固定模板 HTML，动态内容做 HTML/JS/URL 编码；响应包含 `Cache-Control: no-store`、`Referrer-Policy: no-referrer` 和严格 CSP。Sandbox 使用 web paymentUrl；LINE App 内唤起、`appPackageName` 和自定义 Scheme 在生产真机验收。

**Rationale**: 浏览器/WebView 和 App 安装状态决定是否能自动唤起；官方不保证自定义 Scheme 一定被放行，Sandbox 也不能完整模拟 App 环境。

**Rejected**: 接受请求传入 redirect URL；假设自动唤起必然成功。

**Sources**: https://developers-pay.line.me/zh/faq ; https://developers-pay.line.me/zh/api-change-log

## R11. 日志与状态职责

**Decision**: `payment_gateway_log` 仅记录 LINE 请求/响应追加审计，OMG 不迁移；支付/退款业务表仍保存规范状态、稳定键和调度字段。Secret 明文与管理员详情回显风险按用户决定接受，不额外扩大到普通列表或 App 接口。

**Rationale**: 原始日志不能提供可靠的 CAS、唯一性、扫描索引或当前状态；同时普通列表不需要批量传输 Secret。

**Rejected**: 把全部状态只存日志；让 OMG 同步迁移到新日志表。

## R12. Offline 使用自动请款的 My Code Pay

**Decision**: 商家扫码只接 `POST /v4/payments/oneTimeKeys/pay`，台湾默认自动请款；App 仅提交 `ddId` 和 18 位 `oneTimeKey`。金额、TWD、商品摘要、门店、凭证和永久唯一 `lineOrderId` 全部从服务端事实生成。本期不发送设备请求头，不实现 Capture、Void、redirect URL 或授权/请款分离。

**Rationale**: 官方台湾 Offline 流程默认付款即请款，设备头为成对出现的选填字段；当前需求是门店当面收款，不需要额外设备身份或延后请款。

**Rejected**: 让 App 提交金额/币种/orderId；把扫码理解为付款完成；为了未来可能性提前实现 Capture/Void。

**Sources**: https://developers-pay.line.me/zh/offline/implement-payment ; https://developers-pay.line.me/zh/offline-api-v4/request-payment

## R13. 订单来源与支付模式必须持久化

**Decision**: `pos_order.order_source` 使用 `USER/MERCHANT`，数据库默认 `USER`，仅 `/system/orderShOprate/createOrder` 显式写 `MERCHANT`；`pos_order_line_payment.payment_mode` 使用 `ONLINE/OFFLINE`，数据库默认 `ONLINE`，所有新尝试显式写入。历史数据不推断、不回填。

**Rationale**: `payType="3"` 只能表示 LINE Pay 渠道，无法区分网页付款和商家扫码，也无法证明订单来自商家端。持久化判别字段才能稳定驱动鉴权、查单、退款和恢复。

**Rejected**: 新增另一个 payType；根据 paymentUrl、transactionId、创建时间或调用入口日志猜测模式/来源；新建 Offline 专表。

## R14. Offline 结果通过只读 Check 收敛

**Decision**: Offline Pay Read Timeout 为 40 秒，Check/Refund 为 20 秒。`1145/1169/AUTH_READY` 进入 `WAITING_AUTH` 并对商家返回 `AUTH_REQUIRED`；超时、响应丢失、未识别返回码、重复请求待核实和 `REQUEST_UNKNOWN` 返回 `PROCESSING`。只有明确无资金副作用的 Pay 拒绝码和 Check `CANCEL/FAIL` 才能释放活跃键；其他结果只按 `lineOrderId` 调 `GET /v4/payments/orders/{orderId}/check`，禁止重发 Pay 或重用 `oneTimeKey`。

**Rationale**: My Code 是一次性且有时效的授权载体；Pay 可能已经被 LINE 接收，重复提交会产生重复扣款风险。`AUTH_READY/1169` 只表示客户仍需在 LINE Pay 选择付款方式或认证，不是成功。

**Rejected**: Pay 超时后自动再次 Pay；把扫码或 HTTP 200 视为成功；把所有非终态都返回同一个不可操作状态。

**Sources**: https://developers-pay.line.me/zh/offline-api-v4/request-payment ; https://developers-pay.line.me/zh/offline-api-v4/check-payment-status

## R15. Offline 支付事实核对与异常金额收口

**Decision**: 只有 Pay `0000` 或 Check `COMPLETE` 且 `orderId`、字符串 `transactionId`、`payInfo` 金额合计和非空 `paymentProvider` 匹配，才写支付事实。`paymentProvider` 原值保存，`TSP/EPI` 为已知值但不做封闭枚举；缺失进入 `MANUAL_REVIEW`。金额不一致时持久化真实交易和 `captured_amount`，禁止履约，并在同一事务创建以实际扣款额为金额的唯一全额退款；原订单即使退款成功也不再开放支付，商家需新建订单。

**Rationale**: LINE 明确要求核对 `payInfo` 合计并在不一致时退款。保留非空 provider 原值兼顾证据完整与未来渠道扩展；异常资金订单不复用可避免同一业务订单同时承载异常退款和新付款。

**Rejected**: 仅凭 `returnCode=0000` 落账；把 provider 写死为 `TSP/EPI`；金额异常退款后自动释放原订单重扫。

## R16. oneTimeKey 属于禁止持久化的敏感一次性数据

**Decision**: `oneTimeKey` 仅在 Controller DTO 到 `LinePayClient.payOffline` 的单次内存调用链中存在。数据库、普通日志、异常消息、响应和 `payment_gateway_log.payload` 不得包含原值；Offline REQUEST 审计仅保存不含该字段的结构化摘要或固定 `<redacted>`。

**Rationale**: My Code 可触发付款且五分钟内有效，泄漏会扩大未授权扣款与重放风险；现有通用审计包装必须在请求进入日志边界前脱敏。

**Rejected**: 为排障保存原始请求；只在成功日志脱敏但允许异常堆栈携带 DTO；对 oneTimeKey 做可逆加密后落库。

## R17. Offline 退款按持久化模式分派

**Decision**: Offline 全额退款调用 `POST /v4/payments/orders/{lineOrderId}/refund`，Online 保持 `POST /v4/payments/{transactionId}/refund`，两者都省略 `refundAmount`。唯一分派依据是支付行 `payment_mode`；退款响应未知时继续通过 Retrieve/refundList 只读核实。

**Rationale**: 两种 v4 模式的退款 URI 稳定键不同；猜测模式会调用错误端点。现有退款表与 `UNIQUE(payment_id)` 足以保证每笔付款最多一个全退意图。

**Rejected**: 根据 transactionId 是否存在选择端点；为 Offline 再建退款表；UNKNOWN 时直接重发 Refund。

**Sources**: https://developers-pay.line.me/zh/offline/handle-refund ; https://developers-pay.line.me/zh/offline-api-v4/refund

## R18. Offline 并发冲突以数据库事实为准

**Decision**: 所有 Offline 状态更新必须检查版本 CAS 影响行数，失败后重读持久化状态；创建尝试遇到唯一键冲突时复用已有尝试并立即返回，不能再次发送 Pay。首次从 `REQUESTING/REQUEST_UNKNOWN` 进入 `WAITING_AUTH` 时写入 30 分钟认证截止，后续查询保留原截止，不使用创建时的 24 小时未知截止，也不滚动延长。

**Rationale**: 外部支付副作用无法和本地数据库合并为一个事务。把 CAS 失败后的内存对象当事实、或在唯一键冲突后继续 Pay，都会造成错误状态或重复扣款；滚动延长认证截止则会让异常尝试永久占用订单。

**Rejected**: 忽略 Mapper 更新行数；DuplicateKey 后继续原调用；使用 `COALESCE` 无条件保留创建尝试时的未知截止。
