spec.md 48 KB

Feature Specification: LINE Pay Online / Offline API v4 支付接入

Feature Branch: 019-line-pay

Created: 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 回跳。

本规格取代 brainstorm.md 中尚未确认或后来已变更的内容。若两者冲突,以本规格为准。

Clarifications

Session 2026-08-18

  • Q: LINE Pay 返回等待认证或结果未知时,本地状态如何映射? → A: 11451169AUTH_READYWAITING_AUTH 返回 AUTH_REQUIRED;超时、响应丢失、重复请求待核实及 REQUEST_UNKNOWN 返回 PROCESSING
  • Q: paymentProvider 应如何校验? → A: 必须非空并原值保存;TSPEPI 是已知值,其他非空值也允许;缺失或空值进入 MANUAL_REVIEW

1. 已定决策

议题 最终决策
支付渠道编号 pos_order.pay_type="3" 表示 LINE Pay 直连;不再表示 ZaloPay
与 OMG 的关系 LINE Pay 与 OMG 并存,不替换 OMG
OMG 数据范围 pos_store_omgpos_order_omg_paymentpos_order_omg_refund、OMG 的 ipn_log 写入及既有回调原文全部保持现状
LINE Pay 日志 新建公共命名的 payment_gateway_log,首版只写 LINE Pay;不迁移、不复制 OMG 日志
API 与环境 LINE Pay Online API v4;首版接 Sandbox,生产地址仅预留服务端配置
凭证粒度 每个 pos_store 独立 Channel ID / Channel Secret;订单按 PosOrder.mdId 选择凭证
凭证验证 保存时使用无扣款 Retrieve 探测;探测通过后保存为新版本并自动启用,失败时保留旧版本
Secret 策略 按已批准风险,Channel Secret 首版明文保存;平台管理员详情页/查询接口允许回显;不设“日志、异常、响应必须隐藏 Secret/HMAC”的验收限制
确认与请款 使用普通支付默认浏览器回跳并快速返回安全提示页,Confirm 自动请款;不实现授权/请款分离、Capture 或 Void 操作
退款 仅全额退款;用户不直接调用独立退款接口,用户/商家取消订单后自动退款;平台管理员可人工全额退款
主动查询 新建独立定时任务主动 Check/Retrieve/Confirm 并更新支付、退款及订单状态;不使用废弃的 TestTask
App 回跳 固定 com.twanmsdyh.app://payment/result?orderId=<ddId>;App 打开后必须调用查询接口取得最终状态,不信任 Scheme 参数判断支付结果
安全提示页 由 Spring Controller 直接返回自包含 HTML,不是独立 Vue 页面,也不需要另行部署静态站点
金额与币种 固定 TWD、整数金额;所有金额只取服务端订单事实
首版订单范围 只支持单门店餐饮订单;多门店父单不得发起 LINE Pay

1.1 已接受的安全风险

本期明确接受以下风险,不将其作为阻断条件:

  • Channel Secret 明文落库。
  • 具备 LINE Pay 平台管理权限的管理员可通过详情接口和页面查看 Secret。
  • 不新增 Secret/HMAC 强制脱敏验收规则。

真实 Secret 仍不得写入本规格、源码常量或测试夹具。接口继续受平台权限控制;列表接口不批量返回 Secret,详情接口才按权限返回,以免无意义扩大数据量和暴露面。

2. 范围与非目标

2.1 本期范围

  • foodie_server:LINE Pay v4 客户端、凭证、支付、Confirm、查询、全额退款、状态机、定时恢复、订单联动、平台管理接口和中间提示页。
  • foodie-admin-vue:把现有 LINE Pay“即将上线”占位页替换为门店凭证管理页,并在订单管理中提供 LINE Pay 查询与全额退款操作。
  • App 契约:创建支付、查询状态和固定 Scheme 回跳;本期不修改用户端仓库。
  • 数据库变更:只追加到 updatesql/sql.md,不直接执行。

2.2 非目标

  • 不调整 OMG 三张业务表,不调整 OMG 的日志、回调或退款数据结构。
  • 不重构为通用支付框架;只增加防止 OMG 与 LINE Pay 并发发起的最小订单级协调。
  • 不修改或复活废弃的 ZaloPay Controller、Service、表或定时任务。
  • 不迁移历史 pay_type="3" 订单。任何 LINE Pay 查询、退款或补偿必须同时找到匹配的 LINE Pay 流水,不能只看 payType=3
  • 不支持部分退款、重复付款、预授权、分次请款、Void、旅游或机票订单。
  • 不在 Sandbox 声称完成 LINE App payment URL、EPI 或真实 App 自动唤起验收。

3. 用户场景与验收

User Story 1 - 用户完成 LINE Pay 支付(P1)

用户对一笔单门店、未支付、可支付的餐饮订单发起 LINE Pay,系统返回 Sandbox Web 收银台地址。用户完成 LINE 认证后立即看到“支付结果确认中”的安全提示页,页面尝试打开 App;后台异步 Confirm 并由 App 查询最终状态。

Independent Test:从待支付订单发起 Sandbox 支付,完成认证后回到服务端中间页,任务完成 Confirm,订单最终变为已支付。

Acceptance Scenarios

  1. Given 门店凭证已启用且订单可支付,When 用户发起 LINE Pay,Then 返回同一活跃流水的 paymentUrl.web、字符串交易号和查询所需标识。
  2. Given 用户完成 LINE 认证,When LINE 请求 confirmUrlThen 服务端持久化回跳事实并快速返回中间页,不同步等待最长 40 秒的 Confirm。
  3. Given 异步 Confirm 或 Retrieve 证实支付已 Capture,When App 查询,Then 返回已支付,且订单只核销、推送一次。
  4. Given App 已安装且系统允许 Scheme 跳转,When 中间页加载,Then 正常尝试打开 App;若未安装或被 WebView 拦截,页面继续显示提示和“打开 App”按钮。

User Story 2 - 凭证保存、验证和自动启用(P1)

平台管理员录入门店 Channel ID / Channel Secret。服务端先使用该组未落库凭证查询随机不存在的 LINE orderId;通过后创建不可变凭证版本并自动设为当前启用版本。

Independent Test:正确凭证返回预期探测码并自动启用;错误或网络未知不覆盖当前版本。

Acceptance Scenarios

  1. Given 正确 Sandbox 凭证,When 管理员保存,Then Retrieve 返回 1150,或返回结构有效的 0000,系统标记认证探测通过、保存新版本并自动启用。
  2. Given 返回 110411051106 或明确鉴权失败,When 管理员保存,Then 拒绝新版本,当前版本保持不变。
  3. Given 网络超时、9000 或结果未知,When 管理员保存,Then 返回“验证结果未知”,不保存、不切换、不停用旧凭证。
  4. Given 旧版本仍有关联交易,When 新版本启用,Then 旧交易优先使用其原 credential_id;若旧版本明确鉴权失败,只允许同门店、同环境、同 Channel ID 的当前版本先经严格 Retrieve 证明交易归属,再用于本轮恢复,绝不尝试不同 Channel 的凭证。

LINE Pay 没有专用的凭证校验接口。上述 1150/0000 仅表示该签名与 Channel 组合被网关接受,不证明门店归属、TWD/Online 权限或真实扣款能力。自动启用是本项目已批准的业务策略;真实支付能力仍须 Sandbox 端到端验收。


User Story 3 - 主动查询和未知结果恢复(P1)

即使用户关闭页面、回跳丢失、Confirm/Refund 响应丢失或服务重启,独立任务仍能主动查询 LINE Pay 并恢复最终资金事实。

Independent Test:模拟丢回跳、Confirm 超时和 Refund 超时,任务通过 Check/Retrieve 恢复且不重复扣款/退款。

Acceptance Scenarios

  1. Given Check 返回 0000When 任务处理,Then 保持等待,不标记支付成功。
  2. Given Check 返回 0110 且订单仍可支付,When 任务取得行级执行权,Then 自动调用一次 Confirm。
  3. Given Check 返回 0110 但订单已取消,When 任务处理,Then 不 Confirm、不主动扣款,继续查询直至网关给出终态或进入人工核对。
  4. Given Check 返回 0121When Retrieve 未发现已支付事实,Then 标记取消或过期并释放该订单的 LINE 活跃尝试。
  5. Given Check 返回 0122When Retrieve 未发现已支付事实,Then 标记失败并允许新的支付尝试。
  6. Given Check 返回 0123When 任务处理,Then 必须再 Retrieve 核实 Capture 后才标记已支付。
  7. Given Confirm/Refund 超时或返回未知,When 恢复任务运行,Then 先 Retrieve,绝不盲目重复有副作用的请求。

User Story 4 - 取消订单和全额退款(P1)

用户或有权商家通过现有真实取消入口取消订单。若 LINE Pay 已支付,系统创建唯一全额退款意图并调用 Refund;若支付成功迟到,则先记录真实支付,再自动创建全额退款意图。

Independent Test:分别重放“支付先成功再取消”和“取消先成功、支付结果后到”两种时序,最终只发生一次全额退款。

Acceptance Scenarios

  1. Given LINE Pay 已支付且订单未完成,When 用户或商家取消,Then 订单先按现有 CAS 取消,再创建唯一退款记录并全额退款。
  2. Given 订单先取消、Confirm 后证实已支付,When 支付事实落库,Then 系统保留已付款事实并创建唯一退款记录,不丢弃迟到付款。
  3. Given Refund 响应丢失,When 用户、管理员或任务再次处理,Then 只 Retrieve 核实,不再次调用 Refund。
  4. Given Retrieve 证实全额退款,When 状态落库,Then 订单 payStatus 才更新为已退款,并执行既有退款后的订单/积分联动。
  5. Given 订单已完成,When 尝试通过本功能退款,Then 按现有订单生命周期拒绝。

User Story 5 - 平台管理与人工恢复(P2)

平台管理员可查看门店开通状态、维护凭证、启停新支付、查看 LINE Pay 状态,并对未知交易人工执行“查询核实”或对可退款交易执行“全额退款”。

Independent Test:以不同权限账号访问列表、Secret 详情、保存、启停、订单查询和退款,权限与状态门禁正确。

Edge Cases

  • Request 已被 LINE 接受但响应或本地落库失败。
  • confirm/cancel 回跳被伪造、重复、乱序,或 cancel 不含任何交易参数。
  • 用户认证完成后订单先被取消,任务随后看到 Check 0110
  • Confirm/Refund 已在网关执行,但服务端超时或重启。
  • 两台应用节点、用户回跳、定时任务和管理员同时处理同一流水。
  • 凭证轮换后继续处理旧 Channel 下的在途支付和已付交易退款。
  • 历史 ZaloPay payType=3 订单与新 LINE Pay 订单共存。
  • 堂食订单初始 state=1,以及多门店父单包含不同 mdId

4. 系统边界

4.1 ruoyi-system

只承载实体、Mapper XML、Service 和数据库条件更新,不依赖 LINE Pay HTTP 客户端,不导入 com.ruoyi.app.*

  • pos_store_line_pay:门店凭证的不可变版本。
  • pos_order_line_payment:每次真正发起的新 LINE Request 各保存一条支付尝试;历史尝试永不覆盖。
  • pos_order_line_refund:每笔已付款交易最多一条全额退款状态。
  • payment_gateway_log:LINE Pay 原始交互与结果码的追加日志。

4.2 ruoyi-admin

  • LinePayClient:v4 HTTP、精确签名、超时和 DTO 解析。
  • LinePayService:凭证探测、创建、回跳登记、Confirm、Retrieve、Check、退款及状态机。
  • LinePayController:App 创建/查询接口和匿名回跳页。
  • PosStoreLinePayController:平台门店凭证管理。
  • LinePayReconcileTask:独立主动查询与恢复任务;只调用 Service,不调用 Controller。
  • 现有用户/商家取消入口:增加 LINE Pay 分派,并补齐商家对订单的门店归属鉴权。
  • 现有订单管理:增加 LINE Pay 状态上下文、人工查询与全额退款。
  • 订单级支付协调:LINE Pay 和 OMG 创建入口共同使用,防止同一订单并发发起两个渠道;不改 OMG 表和 OMG 日志。

4.3 foodie-admin-vue

  • 现有 src/views/mendian/storePayment/index.vue 保留 OMG Tab,把 LINE Pay 占位内容替换为独立 LinePayTab
  • 新增 src/api/chanting/storeLinePay.js
  • 四语文件使用仓库真实路径:
    • src/api/language/language.zh_CN.js
    • src/api/language/language.zh_TW.js
    • src/api/language/language.en_US.js
    • src/api/language/language.vi.js
  • 新 key 放在 storeLinePay 嵌套对象中,使用有意义的英文驼峰名称。

5. 数据模型

5.1 pos_store_line_pay

每次验证通过生成新行,旧行不覆盖、不删除,以便历史交易继续使用原凭证。

关键字段:

字段 约束与含义
id 主键,同时作为支付流水的 credential_id
store_id pos_store.id
credential_version 门店内递增版本;唯一 (store_id, credential_version)
channel_id LINE Channel ID
channel_secret 按已批准策略明文保存
verify_status AUTH_PROBE_VERIFIED
verify_return_code/message 最近保存探测结果摘要
verified_time 探测通过时间
current_store_id 当前版本时等于 store_id,历史版本为 NULL;唯一索引保证每店只有一个当前版本
is_enabled 当前版本是否允许发起新支付;禁用不影响旧交易查询/退款
并发与审计字段 version/create_by/create_time/update_by/update_time

验证通过后的版本切换在一个数据库事务内完成:旧版本 current_store_id=NULL,is_enabled=0,新版本 current_store_id=store_id,is_enabled=1。并发管理员切换使用版本 CAS;失败者重新读取当前版本。

5.2 pos_order_line_payment

只保存支付状态事实和恢复调度字段,不保存每次网关 returnCode 或整段原始响应。

该表与订单是 1:N:同一 dd_id 可以有多条历史支付尝试,但任意时刻最多只有一条当前活跃尝试。用户对仍在等待或处理中的尝试重复点击支付时复用原记录;只有原尝试已被 LINE 明确判定取消、过期或失败后,重新支付才插入新记录。旧记录及其 line_order_idtransaction_idcredential_id 永不被新尝试覆盖。

关键字段:

字段 约束与含义
id 主键
dd_id pos_order.dd_id,字符串业务订单号;普通索引、允许重复,不设唯一约束
line_order_id Request 前生成并提交,VARCHAR(100)、ASCII/BINARY 比较,非空永久唯一,实际值不超过 100 字符
transaction_id LINE 19 位交易号,VARCHAR/Java String/JS String,可空唯一,禁止 Long/Number
credential_id 不可变引用 pos_store_line_pay.id
store_id 发起时门店快照
amount/currency TWD 整数金额和 TWD
payment_url Sandbox 的 paymentUrl.web
payment_provider 保存 LINE 原始返回值;NULL 在业务展示时可解释为 TSP,但数据库不伪造原值
status 下述支付状态
active_dd_id 阻断新尝试时等于 dd_id,明确取消/过期/失败后为 NULL;唯一索引保证每订单仅一条阻断性 LINE 流水
version 行级 CAS 版本
恢复字段 next_reconcile_at/reconcile_deadline/reconcile_count/lease_owner/lease_until/status_changed_at
事实时间 auth_completed_time/pay_time/create_time/update_time

支付状态:

REQUESTING
REQUEST_UNKNOWN
WAITING_AUTH
READY_CONFIRM
AUTH_DONE_ORDER_CANCELLED
CONFIRMING
CONFIRM_UNKNOWN
PAID
CANCELLED_OR_EXPIRED
FAILED
MANUAL_REVIEW

PAID、所有 UNKNOWNMANUAL_REVIEW 继续占用 active_dd_id;只有网关明确 01210122 或明确无副作用失败才能释放。支付一旦 PAID 永不改写为退款状态。

支付行只允许按照状态机 CAS 推进状态和补充本次尝试的网关结果;不得把另一支付尝试的交易号、凭证或支付地址写入旧行,也不得通过覆盖旧行实现“重新支付”。

禁止使用 UNIQUE(dd_id,is_active) 并把历史行写成 0,因为这会导致同一订单只能保存一条历史失效记录。DDL 使用可空 active_dd_id 唯一索引或等价生成列。

5.3 pos_order_line_refund

退款独立保存,不污染支付状态。

关键字段:idpayment_id(唯一,保证全额退款只创建一次)、refund_transaction_id(字符串、可空唯一)、amountstatusversion、恢复调度/租约字段、refund_time/create_time/update_time

退款状态:

CREATED
PROCESSING
UNKNOWN
RETRY_WAIT
REFUNDED
FAILED
MANUAL_REVIEW

UNKNOWN 不得直接再次 Refund。仅官方明确可重试的 190019021999 可进入受控 RETRY_WAIT;其他明确失败进入 FAILED 或人工核对。

5.4 payment_gateway_log

该表首版只写 provider=LINE_PAY。它记录“发生过什么”,不作为支付/退款状态机事实;日志写入失败不得回滚已经确认的资金事实。

关键字段:

id, provider, correlation_id,
store_id, credential_id, payment_id, refund_id, dd_id,
gateway_order_id, transaction_id,
action, direction, source,
http_status, return_code, return_message,
success, duration_ms, payload, create_time
  • action 包含 CREDENTIAL_VERIFY/REQUEST/CHECK/CONFIRM/RETRIEVE/REFUND/REDIRECT_CONFIRM/REDIRECT_CANCEL
  • direction 区分请求、响应和本地事件;同次 HTTP 请求/响应共用 correlation_id
  • 本规格中 0000/0110/0121/0122/0123/1150 等原始码记录在本表;支付/退款表只保存规范状态和调度字段。
  • 索引至少覆盖 (payment_id,create_time)(refund_id,create_time)(dd_id,create_time)(store_id,create_time)(gateway_order_id,create_time)(transaction_id,create_time)correlation_id

6. LINE Pay API 契约

6.1 v4 端点

POST /v4/payments/request
GET  /v4/payments/requests/{transactionId}/check
POST /v4/payments/{transactionId}/confirm
GET  /v4/payments
POST /v4/payments/{transactionId}/refund
  • Sandbox base URL 固定为 https://sandbox-api-pay.line.me,不可由请求参数控制。
  • payType="3" 只属于本项目,绝不发送给 LINE;普通支付省略 LINE 的 options.payment.payType
  • 自动请款使用默认行为或显式 options.payment.capture=true
  • Request 发送官方公开契约中的回跳字段;普通支付省略未被当前 v4 公共页面明确保证的 payTypeconfirmUrlTypeappPackageName,使用默认浏览器回跳:

    {
    "redirectUrls": {
    "confirmUrl": "https://foodieapi.waimai-paotui.com/pay/line/confirm",
    "cancelUrl": "https://foodieapi.waimai-paotui.com/pay/line/cancel"
    }
    }
    
  • Request 的 amount 必须等于 packages 金额之和,每个 package 金额必须等于 products 的 price × quantity 之和。首版使用一个服务端生成的 package/product,金额等于订单应收整数金额。

  • Refund 为全额退款时省略 refundAmount;Refund 请求没有 currency 字段。

6.2 HMAC

请求头:

Content-Type: application/json
X-LINE-ChannelId
X-LINE-Authorization
X-LINE-Authorization-Nonce
GET:  channelSecret + apiPath + exactQueryString + nonce
POST: channelSecret + apiPath + exactRequestBody + nonce

使用 Channel Secret 作为 HMAC-SHA256 key,再 Base64。POST JSON 只序列化一次并将同一字节串用于签名和发送;GET 的参数顺序、编码和值必须与最终 URL 完全一致;同一次请求的签名和请求头使用同一 UUID v4 nonce。

建议读取超时下限:Request 10 秒、Check/Retrieve/Refund 20 秒、Confirm 40 秒。HTTP 200 不表示业务成功,必须解释 returnCode

6.3 Retrieve 的支付事实判定

returnCode=0000 只表示查询成功,不等于已付款。必须在 info[] 中找到唯一匹配本地 line_order_id + transaction_id + currency 的记录,并确认:

  • transactionType=PAYMENT
  • payInfo[].amount 合计与本地金额一致;Online v4 当前公开 Retrieve 契约未保证 payStatus 字段,因此不依赖该字段
  • 金额与本地服务端金额相符
  • 门店与 credential_id 相符

AUTHORIZATION 不是已结算。空数组、多条歧义或字段不匹配全部进入未知/人工核对。退款事实同时检查 refundList,不能只根据原 PAYMENT 仍为 CAPTURE 判断“未退款”。

7. 业务流程与事务边界

7.1 创建支付

  1. 用 token 校验用户订单归属,参数统一使用 ddId,不混用 pos_order.id、父单号和 LINE orderId
  2. 只允许单门店实际 pos_order;多门店父单直接返回不支持 LINE Pay。
  3. 校验订单未支付、未完成、未取消,堂食 state=1 仍可支付;金额为正整数 TWD。
  4. mdId 取得当前启用凭证版本。
  5. LINE create 必须要求订单当前 payType=3;OMG create 必须要求订单当前 payType=7。create 不允许临时切换支付渠道。
  6. LINE 与 OMG 两个 create 都在同一个 pay:create:<ddId> Redisson 分布式锁内重新读取订单,并以 pay_status=0、非终态、pay_type=目标渠道 作为数据库条件门禁。锁获取失败直接返回处理中,不降级为无锁创建。现有 OMG create 只做该最小校验/锁调整,不改 OMG 表或日志。
  7. 在独立本地事务中先提交 REQUESTING + line_order_id + credential_id + active_dd_id,再调用外部 Request。
  8. Request 成功后以另一事务 CAS 保存 transaction_id/payment_url 并进入 WAITING_AUTH。超时、响应丢失或落库失败进入 REQUEST_UNKNOWN,由 Retrieve 按 line_order_id 恢复。

重复创建规则:

  • WAITING_AUTH:查询现有尝试后返回同一有效 paymentUrl
  • READY_CONFIRM/CONFIRMING/UNKNOWN:返回“处理中”,不创建新尝试。
  • PAID:直接返回已支付。
  • CANCELLED_OR_EXPIRED/FAILED:旧行保留并释放 active_dd_id;重新支付插入新行并生成新的永久唯一 line_order_id,不更新或覆盖旧行。

7.2 confirm/cancel 回跳和中间页

LINE 自动在 confirm URL 追加 orderId(即本地 line_order_id)和 transactionId。cancel URL 的两项参数可能缺失。两个 GET 都无签名、可伪造,不是最终资金事实。

  • /confirm:匹配本地流水并 CAS 登记 READY_CONFIRM,将 next_reconcile_at 提前,然后快速返回 HTML;不在浏览器请求内同步调用 Confirm。
  • /cancel:只记本地事件并触发尽快 Check,不直接标记取消,不取消外卖订单。
  • 参数不匹配或 cancel 缺参数时返回同一中性提示页,不暴露内部交易详情。
  • 页面只显示“支付结果正在确认,请返回 App 查看”,不显示未经查询的成功/失败结论。
  • 页面从已匹配的本地流水取得 ddId,构造固定 Scheme:

    com.twanmsdyh.app://payment/result?orderId=<URL-encoded ddId>
    
  • 页面加载后尝试一次 Scheme,并提供手动“打开 App”按钮;无法唤起时页面继续保留安全提示。

  • 固定 Scheme 不接受请求参数指定跳转目标,避免开放重定向。

  • HTML/JS/URL 参数正确转义,内联脚本使用每响应 CSP nonce;响应使用 text/html;charset=UTF-8Cache-Control: no-storeReferrer-Policy: no-referrerX-Content-Type-Options: nosniff 和限制性 CSP/frame-ancestors 'none'

7.3 Confirm 与支付落账

执行者先以 CAS 和行租约把状态变为 CONFIRMING,再调用外部 Confirm:

  • 调用前重新核对 active_dd_id、订单状态、门店、credential_idline_order_idtransaction_id、金额和币种。
  • 若订单已取消且尚未扣款,不调用 Confirm,进入 AUTH_DONE_ORDER_CANCELLED 并等待网关过期终态。
  • Confirm 0000 后验证响应并在本地事实事务中把支付置为 PAID
  • Confirm 超时、119811999000 或本地落库未知时置为 CONFIRM_UNKNOWN,只能 Retrieve 恢复。
  • 若订单仍处于合法未完成状态,CAS 更新 pos_order.pay_type=3,pay_status=1,并只触发一次现有履约/推送副作用。堂食初始 state=1 也允许核销。
  • 若订单已经 state=4,仍先记录 PAID 和订单已付款事实,并在同一事务插入唯一退款意图;不触发履约。

商家接单必须阻止真实 LINE 未付款订单进入履约。真实 LINE 订单禁止走旧 /setorderuzt,只能使用当前用户、商家、骑手专用订单操作入口;历史 payType=3 且没有 LINE 流水的订单不按新 LINE 订单处理。

7.4 主动查询状态映射

恢复时优先 Retrieve;未发现已支付事实才使用 Check:

Check code 本地动作
0000 保持等待认证
0110 标记可 Confirm;订单仍可支付才自动 Confirm
0121 Retrieve 未发现付款后标记 CANCELLED_OR_EXPIRED
0122 Retrieve 未发现付款后标记 FAILED
0123 调 Retrieve;只有证实 PAYMENT/CAPTURE 才标记 PAID

任何 Check 结果都不能覆盖已由 Retrieve/Confirm 确认的 PAID

7.5 全额退款

  1. 用户取消和商家取消继续通过当前真实入口;商家入口在状态变更前必须按 userType/shId/mdId 规则验证订单归属。
  2. 取消成功后读取最新资金事实。若 LINE 已支付,执行 insert-if-absent(payment_id) 创建 CREATED 退款;若尚未支付,迟到支付落账事务负责补建。
  3. 退款执行者 CAS 到 PROCESSING,先 Retrieve 排除已经退款,再调用一次省略 refundAmount 的全额 Refund。
  4. Refund 0000 且返回 refundTransactionId 后置 REFUNDED,再更新订单 payStatus=2 和既有退款后状态/积分。
  5. 超时或未知置 UNKNOWN;通过原 PAYMENT 的 refundList 或 refundTransactionId Retrieve 恢复,禁止直接重发 Refund。
  6. 管理退款在调用网关前,必须在订单行锁事务内创建唯一退款意图;全部真实 LINE 完成入口以同一条件更新拒绝存在非 FAILED 退款意图的订单,确保“完成订单”和“开始退款”最多只有一方成功占位,已经退款的订单也不能再完成。
  7. 租约过期的 PROCESSING 恢复为 UNKNOWN 时必须初始化未知截止时间;截止前每次无结论 Retrieve 必须以版本 CAS 后移 next_reconcile_at,截止时的最后一次 Retrieve 不再重排,而是以当前版本可靠落入 MANUAL_REVIEW,避免旧未知记录长期占据批次队首或永久占用退款意图。
  8. REFUNDED 是不可降级终态;通用人工核对 CAS 永远不得覆盖它。只有 Retrieve 已严格证实存在部分或歧义退款证据时,专用证据 CAS 才可将 FAILED 等非退款终态升级为 MANUAL_REVIEW,阻止订单在已出现退款事实时继续履约;Retrieve 严格证实全额退款时,另一专用证据 CAS 可将 FAILED 升级为 REFUNDED,但不得覆盖既有 MANUAL_REVIEW/REFUNDED
  9. 平台人工退款与自动退款复用同一唯一退款行和状态机,不能绕过幂等门禁。

7.6 查询定位规则

查询不依赖覆盖旧记录,而是按查询目的使用稳定键:

  1. App 按 ddId 查询:先读取订单资金状态。订单 payStatus=1 时选择唯一一条尚未证实全额退款的 PAID 支付行;payStatus=2 时返回已证实全额退款的支付/退款事实;未支付时查询唯一的 active_dd_id=ddId;没有活跃行时返回 create_time,id 倒序的最近一条终态尝试。若出现多条尚未全额退款的 PAID,则不自动任选,返回人工核对状态。
  2. LINE confirm/cancel 回跳:按唯一 line_order_id 精确定位;同时带有 transactionId 时还必须与同一行匹配。历史回跳只处理历史行,不得更新当前活跃行。
  3. 定时任务和人工查询:按支付主键领取行租约,再使用该行自己的 line_order_id/transaction_id/credential_id 查询 LINE。
  4. 退款:只按已确认的 PAID payment_id 创建或读取唯一退款行,不按“最近一条支付”猜测。
  5. 平台历史:按 dd_id 查询全部尝试并按 create_time,id 倒序展示。

若已释放的历史尝试后来被 Retrieve 证实实际支付成功,系统必须在该历史行记录真实 PAID。订单尚未支付时,以该事实核销订单并阻止其他尝试 Confirm;订单已由另一尝试支付时,把迟到的重复付款转入自动全额退款或人工核对,不能覆盖任一支付行。

8. 定时任务设计

LinePayReconcileTask 位于 ruoyi-admin/src/main/java/com/ruoyi/app/task,默认每 60 秒启动一轮:

  • 使用独立 Redisson 锁 key 和 watchdog 自动续租,不使用易在长 Confirm 中过期的固定短 lease。
  • 每轮分页选择 next_reconcile_at <= now 的非终态支付/退款,默认批量上限 20,并为支付、取消补偿和退款保留独立处理机会;支付行领取租约时同步后移 next_reconcile_at,避免慢响应记录反复占据队首,一笔失败不影响其他记录。
  • 每行使用 lease_owner/lease_until/version CAS claim。即使全局锁失效或人工操作并发,也只有一个副作用执行者。
  • 索引覆盖 (status,next_reconcile_at,id);按状态退避并递增 reconcile_count
  • 等待认证默认追踪 30 分钟。到期前最后一次 Retrieve/Check;若仍无明确终态,进入 MANUAL_REVIEW 并保留 active_dd_id
  • Request/Confirm/Refund 未知默认追踪 24 小时。截止前最后一次 Retrieve;仍未知则 MANUAL_REVIEW,绝不释放活跃键或自动重试副作用。
  • 被取消订单不排除在扫描外;已 Capture 的迟到付款必须进入自动全额退款。

以上周期、批量和期限使用服务端配置,可调但不得由客户端请求控制。

9. 应用和平台接口

9.1 App 接口

POST /pay/line/create
POST /pay/line/query
GET  /pay/line/confirm
GET  /pay/line/cancel
  • create/query@Anonymous + @Auth + @RequestHeader String token + @RequestBody 明确 DTO;请求字段为 ddId
  • confirm@Anonymous,显式必填 @RequestParam String orderIdtransactionId
  • cancel@Anonymous,显式 @RequestParam(required=false) 接收可能缺失的 orderIdtransactionId
  • Controller 入参禁止 Map;DTO 不使用 Bean Validation 注解,业务校验通过项目 i18n 机制返回。
  • 不提供用户直接退款 API。

创建响应包含:ddIdpaymentIdlineOrderId、字符串 transactionIdpaymentUrl、规范支付状态、reusedAttempt。查询响应按 7.6 的规则包含所选 paymentId、订单支付状态、LINE 规范支付状态、退款状态和更新时间;不以 Scheme 或回跳参数作为结果。

9.2 平台接口和权限

门店管理至少提供列表、详情、保存并验证凭证、启停当前版本。订单管理至少提供查询核实和全额退款。

权限拆分:

chanting:storePayment:list
chanting:storeLinePay:list
chanting:storeLinePay:query
chanting:storeLinePay:saveCredentials
chanting:storeLinePay:toggleEnable
system:order:linePaymentReconcile
system:order:lineRefund

现有支付管理菜单入口从仅 OMG 权限调整为公共 storePayment:list,避免只有 LINE 权限时进不了页面。所有 SQL 只写入 updatesql/sql.md

10. 功能要求

  • FR-001:系统 MUST 把当前有效订单业务中的 payType="3" 定义为 LINE Pay 直连,并更新 PosOrderOrderPositionInfo 等仍被有效链路引用的注释;废弃 ZaloPay 实体、Service、Controller、配置和历史字段语义不做增量修改,LINE Pay 使用独立配置前缀和独立流水识别。
  • FR-002:系统 MUST 仅允许订单本人对单门店、未支付、未取消、未完成的餐饮订单发起 LINE Pay;MUST 拒绝多门店父单。
  • FR-003:系统 MUST 按订单 mdId 使用当前已启用且探测通过的门店凭证版本,金额固定取服务端订单的整数 TWD。
  • FR-004:系统 MUST 使用 Online API v4,并保证签名 JSON/query 与实际发送字节一致;MUST NOT 把本地 payType=3 发送为 LINE API 的 options.payment.payType
  • FR-005:系统 MUST 在调用 Request 前插入并持久化永久唯一 line_order_id 和支付意图;同一订单允许多条历史支付尝试但最多一条活跃尝试。重复点击 MUST 复用活跃行,只有旧尝试被明确终止后才插入新行,任何重新支付 MUST NOT 覆盖旧行。
  • FR-006:系统 MUST 把 confirm/cancel 当作不可信浏览器事件;confirm MUST 快速返回服务端 HTML 中间页,不同步等待 Confirm,不直接宣告支付成功。
  • FR-007:系统 MUST 由独立定时任务主动执行 Retrieve、Check 和必要的 Confirm,并严格按 0000/0110/0121/0122/0123 映射推进状态。
  • FR-008:系统 MUST 仅在 Confirm 成功响应通过核对,或 Retrieve 唯一证实 PAYMENT/CAPTURE 后记录已支付;Check、HTTP 200 和回跳到达均不是最终资金事实。
  • FR-009:系统 MUST 通过 CAS 将外送和堂食合法非终态订单核销为已支付,且履约/推送副作用只执行一次;未付 LINE Pay 订单不得被商家接单或旧入口绕过。
  • FR-010:系统 MUST 处理取消与迟到支付竞态;订单已取消时仍记录真实付款,并创建唯一自动全额退款意图。
  • FR-011:系统 MUST 仅支持全额退款,调用 Refund 时省略 refundAmount;退款未知时 MUST Retrieve,禁止盲目重复 Refund,只有证实退款后才更新订单已退款状态。
  • FR-012:系统 MUST 以不可变版本保存门店凭证,支付流水引用 credential_id;新凭证探测失败或未知不得覆盖当前版本,旧交易继续使用原版本。
  • FR-012A:旧凭证明确鉴权失败时,系统 MUST 仅对同门店、同环境、同 Channel ID 的当前版本执行严格只读 Retrieve;证据完整匹配后才可继续本轮恢复,不得改写支付流水原 credential_id
  • FR-013:系统 MUST 将 LINE Pay 交互追加到 payment_gateway_log,原始结果码不塞入支付/退款业务表;该日志首版 MUST NOT 接管或迁移 OMG 日志。
  • FR-014:系统 MUST 提供平台门店凭证管理、启停、订单人工查询与全额退款,并用独立权限控制;Secret 列表不批量返回,详情可按已批准策略回显。
  • FR-015:平台新增可见文本 MUST 使用 $t() 并同步简中、繁中、英文、越南文四份实际语言文件,key 使用 storeLinePay 下有意义的英文驼峰名称。
  • FR-016:系统 MUST 保持 OMG 三张业务表、OMG ipn_log 和既有原始回调结构不变;只允许为防跨渠道并发,对 OMG create 增加相同订单级锁和渠道一致性门禁。
  • FR-017:所有 LINE 查询、补单和退款 MUST 同时要求存在匹配的 LINE 支付流水;回跳按 line_order_id/transaction_id、任务按 payment_id、退款按已付 payment_id 精确处理,MUST NOT 仅凭历史 payType=3 或“最近一条”猜测资金记录。
  • FR-018:所有 DDL、权限和菜单 SQL MUST 只追加到 updatesql/sql.md,不得由实现过程直接执行。
  • FR-019:系统 MUST 按已批准风险明文保存并允许平台权限详情回显 Secret;本期不增加 Secret/HMAC 强制脱敏验收,但不得把真实凭证硬编码进源码或测试数据。
  • FR-020:App Scheme 只负责返回 App;App MUST 使用 token 和 ddId 调查询接口取得最终支付/退款状态,不得信任 URL 参数得出支付结果。

11. 错误处理与审计

  • 外部 HTTP、JSON 解析和数据库落库分别记录阶段,不能把 HTTP 200 当成功。
  • Request/Confirm/Refund 遵循“先持久化意图、再调用外部、最后 CAS 落事实”;外部成功而本地失败时必须能通过 Retrieve 恢复。
  • 明确失败、未知、可重试失败分别进入不同规范状态;接口向用户返回可操作的本地状态,不回传堆栈。
  • 按已接受风险,本期不增加 Secret/HMAC 强制脱敏规则;但不在源码或本规格中放真实凭证。
  • 历史 payType=3 只有在存在 pos_order_line_payment 且标识匹配时才允许 LINE 查询或退款。

12. 验证要求

12.1 自动化测试

  • HMAC 契约:精确 POST JSON、GET query、空 body、非 ASCII、参数顺序和同 nonce。
  • DTO:19 位 transactionId 在 Java/JSON/前端全程保持字符串。
  • 金额:订单、Request package/product、Confirm 均为同一整数 TWD;全额 Refund 省略 refundAmount
  • 状态机:重复 create、重复/乱序回跳、并发 Confirm、Confirm 超时、Refund 超时、任务与人工操作并发。
  • 数据约束:同订单允许多条永不覆盖的历史尝试但只一条阻断性 LINE 流水、同支付只一条退款、凭证并发轮换只有一个当前版本。
  • 查询定位:App、回跳、定时任务、退款和历史查询分别按 7.6 的稳定键选中正确尝试;旧回跳不得污染新尝试。
  • 订单竞态:支付先成功再取消、取消先发生再迟到付款,最终只一次全额退款。
  • 订单类型:外送 state=0 和堂食 state=1 均可正确核销;未付 LINE 订单不得接单。
  • 历史兼容:没有 LINE 流水的历史 payType=3 不触发 LINE 查询或退款。
  • 多门店父单:明确拒绝,不错误选取任一子单凭证。
  • 权限:商家只能取消自己 shId/mdId 范围订单;平台各 LINE 权限独立生效。
  • 前端:四语 key 集合一致,新增可见文本全部走 $t()

12.2 Sandbox 端到端

  • 正确/错误凭证保存探测与自动启用。
  • Request 返回 paymentUrl.web,完成 Web 收银台认证。
  • 浏览器回跳快速显示中间页,异步 Confirm 后 App 查询接口返回已支付。
  • 主动 Check 的 0000/0110/0121/0122/0123 映射和 Retrieve 二次核实。
  • 全额 Refund 和退款后 Retrieve/refundList 核实。
  • 服务重启、回跳丢失和外部响应超时后的恢复。

12.3 生产前真机验收

Sandbox 官方不支持 App payment URL,也不能模拟 EPI;以下必须在生产启用前单独完成:

  • iOS、Android 与 LINE 内置浏览器的 Scheme 自动唤起和手动按钮兜底。
  • App 已注册并处理 /payment/result,打开后带 token 查询服务端最终状态。
  • LINE 内置浏览器回跳与自定义 Scheme 行为。
  • v4 paymentProvider 的 TSP/EPI 兼容。

13. 成功标准

  • 重复回跳、重复任务和重复人工操作不会造成第二次 Confirm 或第二次全额退款。
  • 重新支付会新增尝试并完整保留旧行;订单查询仍能稳定返回已付、当前活跃或最近终态记录。
  • 回跳接口在 2 秒内返回中间页,不受 Confirm 40 秒读取超时影响。
  • 正常情况下,主动任务在两个调度周期内把可确认或可查询交易推进到最新可证实状态。
  • 任何支付/退款未知结果在截止前持续 Retrieve,截止后进入人工核对且不自动释放订单支付占用。
  • 不存在跨门店凭证使用;凭证轮换后旧交易仍可查询和退款。
  • LINE Pay 变更不修改 OMG 三张表及其既有日志结构。
  • 商家扫码付款仅接受 /system/orderShOprate/createOrder 产生的 MERCHANT 单门店订单;用户端、历史、跨商家和多门店订单在调用 LINE 前全部拒绝。
  • Offline Pay 超时或不明确后只按永久 lineOrderId Check;重复请求、任务并发和新 My Code 均不会触发第二次 Pay。
  • 原始 oneTimeKey 不出现在数据库、响应、异常、普通日志和网关审计中;金额不一致不履约、只产生一个全额退款意图,且原订单永久关闭支付。

14. 假设

  • pos_order.dd_id 是 App 与支付接口使用的业务订单号;LINE 的 orderId 始终指独立 line_order_id,两者不可混用。
  • 公网回跳域名 https://foodieapi.waimai-paotui.com 已具备有效 HTTPS/TLS。
  • 用户端能够在后续版本注册已批准的 App Scheme;在此之前,中间页仍可安全显示提示。
  • 门店停用只阻止新 Request,不阻止使用旧凭证处理已存在交易和退款。

15. 官方资料

16. 2026-08-18 Offline 商家扫码支付增量规格

本节扩展现有 Online API v4 功能。详细设计、官方文档核对结论、状态映射和测试边界见 offline-merchant-scan-design.md。本节与前文冲突时,仅在 Offline 商家扫码支付范围内以本节为准;现有 Online 流程保持不变。

16.1 已确认决策

  • FR-OFF-001:系统 MUST 只修改 foodie_server 后端;商家端为 uni-app,本期不修改 App、foodie-storefoodie-admin-vue
  • FR-OFF-002payType="3" MUST 继续表示 LINE Pay;Online 与 Offline MUST 通过 pos_order_line_payment.payment_modeONLINE/OFFLINE 区分并存。
  • FR-OFF-003:只有 /system/orderShOprate/createOrder 创建且持久化为 order_source="MERCHANT" 的新订单可以发起 Offline 扫码支付;历史订单及用户端订单默认 USER,不得推断或回填来源。
  • FR-OFF-004:商家下单 MUST 验证 token 用户为商家、订单门店属于该商家,并且 items 仅包含一个门店包;普通/夜市商家 MUST 同时校验 shId=userId 与目标 PosStore.userId=userId,禁止本人 shId 组合其他商家的 mdId。扫码支付时 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。只有明确无资金副作用的 Pay 拒绝码或 Check CANCEL/FAIL 才能释放活跃键。
  • FR-OFF-009:系统 MUST 将 11451169AUTH_READY 和本地 WAITING_AUTH 映射为 AUTH_REQUIRED;将付款超时、响应丢失、重复请求待核实和本地 REQUEST_UNKNOWN 映射为 PROCESSING;将 COMPLETE/CANCEL/FAIL 分别映射为 PAID/CANCELLED/FAILEDAUTH_REQUIREDPROCESSING 均 MUST 阻止再次扫码;1169 表示客户仍需选择付款方式并完成认证,不得把扫码动作直接视为支付成功。
  • FR-OFF-010:只有 returnCode="0000" 或状态查询 COMPLETE 且订单号、交易号、payInfo 金额合计和 paymentProvider 通过核对后,系统才能写入支付事实;paymentProvider MUST 非空并原值保存,TSPEPI 是已知值但其他非空值同样允许,缺失或空值 MUST 进入 MANUAL_REVIEW 且不得触发正常履约。币种固定使用本地请求事实 TWD,不得要求付款/状态响应返回未公开保证的币种字段。
  • FR-OFF-011:若 payInfo 合计与订单金额不一致,系统 MUST 持久化真实交易和 LINE 实际扣款额,并在同一事务内以实际扣款额创建唯一全额退款意图;阻止正常履约,未知退款结果继续只读核实。无论退款最终成功、失败或进入人工核对,原订单都永久关闭支付,不允许再次扫码,商家必须创建新订单。
  • FR-OFF-012:Online 和 Offline MUST 共用订单级支付锁和活跃尝试唯一门禁。任一模式存在活跃尝试时,另一模式不得发起;重复 Offline 请求和唯一键冲突只返回已有状态,不发送新 oneTimeKey。所有状态 CAS 失败后 MUST 重读持久化状态,不得用过期对象继续副作用。
  • FR-OFF-013:Offline 全额退款 MUST 使用 POST /v4/payments/orders/{lineOrderId}/refund;Online 退款继续使用现有按 transactionId 的端点,分派依据只能是持久化的 payment_mode
  • FR-OFF-014oneTimeKey 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 接口验收

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 返回 11451169AUTH_READY 或本地状态为 WAITING_AUTHWhen 商家查询,Then 返回 AUTH_REQUIRED、首次使用 30 分钟认证截止且后续不顺延,并阻止第二次扫码;付款超时、响应丢失、未识别返回码、重复请求待核实或本地状态为 REQUEST_UNKNOWN 时返回 PROCESSING 并同样阻止第二次扫码。
  4. Given LINE 返回 COMPLETE、订单号和交易号匹配、payInfo 金额合计一致且 paymentProvider 非空,When 状态落库,Then 原值保存 paymentProvider,并且订单、结算和通知副作用至多执行一次;paymentProvider 缺失或为空时进入 MANUAL_REVIEW 且不触发正常履约。
  5. Given 付款请求达到超时或返回不确定结果,When 恢复任务运行,Then 只按 lineOrderId 查询,不再次请求付款。
  6. Given LINE 返回 CANCEL/FAILWhen 状态落库,Then 释放活跃尝试,客户必须生成新的 My Code 才能再次支付。
  7. Given 支付金额不一致,When 后端处理,Then 持久化实际扣款额、不触发正常履约,并在同一事务创建以实际扣款额为金额的唯一全额退款意图;无论退款结果如何,原订单永久保持支付阻断,商家必须创建新订单。
  8. Given 已支付 Offline 流水需要退款,When 取消订单或平台退款流程处理,Then 使用 lineOrderId 调用 Offline Refund;Online 回归仍使用 transactionId
  9. Given 任意日志或异常路径,When 请求结束,Then 数据库和日志中均不存在原始 oneTimeKey