Feature Branch: 019-line-pay
Created: 2026-08-12
Status: Draft — 完整设计待一次性批准
Input: 在餐饮订单中新增 LINE Pay 直连支付,使用 payType="3",支持门店级凭证、支付确认、全额退款、主动状态查询、平台管理和 App 回跳。
本规格取代
brainstorm.md中尚未确认或后来已变更的内容。若两者冲突,以本规格为准。
| 议题 | 最终决策 |
|---|---|
| 支付渠道编号 | pos_order.pay_type="3" 表示 LINE Pay 直连;不再表示 ZaloPay |
| 与 OMG 的关系 | LINE Pay 与 OMG 并存,不替换 OMG |
| OMG 数据范围 | pos_store_omg、pos_order_omg_payment、pos_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”的验收限制 |
| 确认与请款 | confirmUrlType=CLIENT,普通支付,Confirm 自动请款;不实现授权/请款分离、Capture 或 Void 操作 |
| 退款 | 仅全额退款;用户不直接调用独立退款接口,用户/商家取消订单后自动退款;平台管理员可人工全额退款 |
| 主动查询 | 新建独立定时任务主动 Check/Retrieve/Confirm 并更新支付、退款及订单状态;不使用废弃的 TestTask |
| App 回跳 | 固定 com.twanmsdyh.app://payment/result?orderId=<ddId>;App 打开后必须调用查询接口取得最终状态,不信任 Scheme 参数判断支付结果 |
| 安全提示页 | 由 Spring Controller 直接返回自包含 HTML,不是独立 Vue 页面,也不需要另行部署静态站点 |
| 金额与币种 | 固定 TWD、整数金额;所有金额只取服务端订单事实 |
| 首版订单范围 | 只支持单门店餐饮订单;多门店父单不得发起 LINE Pay |
本期明确接受以下风险,不将其作为阻断条件:
真实 Secret 仍不得写入本规格、源码常量或测试夹具。接口继续受平台权限控制;列表接口不批量返回 Secret,详情接口才按权限返回,以免无意义扩大数据量和暴露面。
foodie_server:LINE Pay v4 客户端、凭证、支付、Confirm、查询、全额退款、状态机、定时恢复、订单联动、平台管理接口和中间提示页。foodie-admin-vue:把现有 LINE Pay“即将上线”占位页替换为门店凭证管理页,并在订单管理中提供 LINE Pay 查询与全额退款操作。updatesql/sql.md,不直接执行。pay_type="3" 订单。任何 LINE Pay 查询、退款或补偿必须同时找到匹配的 LINE Pay 流水,不能只看 payType=3。用户对一笔单门店、未支付、可支付的餐饮订单发起 LINE Pay,系统返回 Sandbox Web 收银台地址。用户完成 LINE 认证后立即看到“支付结果确认中”的安全提示页,页面尝试打开 App;后台异步 Confirm 并由 App 查询最终状态。
Independent Test:从待支付订单发起 Sandbox 支付,完成认证后回到服务端中间页,任务完成 Confirm,订单最终变为已支付。
Acceptance Scenarios:
paymentUrl.web、字符串交易号和查询所需标识。confirmUrl,Then 服务端持久化回跳事实并快速返回中间页,不同步等待最长 40 秒的 Confirm。平台管理员录入门店 Channel ID / Channel Secret。服务端先使用该组未落库凭证查询随机不存在的 LINE orderId;通过后创建不可变凭证版本并自动设为当前启用版本。
Independent Test:正确凭证返回预期探测码并自动启用;错误或网络未知不覆盖当前版本。
Acceptance Scenarios:
1150,或返回结构有效的 0000,系统标记认证探测通过、保存新版本并自动启用。1104、1105、1106 或明确鉴权失败,When 管理员保存,Then 拒绝新版本,当前版本保持不变。9000 或结果未知,When 管理员保存,Then 返回“验证结果未知”,不保存、不切换、不停用旧凭证。credential_id。LINE Pay 没有专用的凭证校验接口。上述
1150/0000仅表示该签名与 Channel 组合被网关接受,不证明门店归属、TWD/Online 权限或真实扣款能力。自动启用是本项目已批准的业务策略;真实支付能力仍须 Sandbox 端到端验收。
即使用户关闭页面、回跳丢失、Confirm/Refund 响应丢失或服务重启,独立任务仍能主动查询 LINE Pay 并恢复最终资金事实。
Independent Test:模拟丢回跳、Confirm 超时和 Refund 超时,任务通过 Check/Retrieve 恢复且不重复扣款/退款。
Acceptance Scenarios:
0000,When 任务处理,Then 保持等待,不标记支付成功。0110 且订单仍可支付,When 任务取得行级执行权,Then 自动调用一次 Confirm。0110 但订单已取消,When 任务处理,Then 不 Confirm、不主动扣款,继续查询直至网关给出终态或进入人工核对。0121,When Retrieve 未发现已支付事实,Then 标记取消或过期并释放该订单的 LINE 活跃尝试。0122,When Retrieve 未发现已支付事实,Then 标记失败并允许新的支付尝试。0123,When 任务处理,Then 必须再 Retrieve 核实 Capture 后才标记已支付。用户或有权商家通过现有真实取消入口取消订单。若 LINE Pay 已支付,系统创建唯一全额退款意图并调用 Refund;若支付成功迟到,则先记录真实支付,再自动创建全额退款意图。
Independent Test:分别重放“支付先成功再取消”和“取消先成功、支付结果后到”两种时序,最终只发生一次全额退款。
Acceptance Scenarios:
payStatus 才更新为已退款,并执行既有退款后的订单/积分联动。平台管理员可查看门店开通状态、维护凭证、启停新支付、查看 LINE Pay 状态,并对未知交易人工执行“查询核实”或对可退款交易执行“全额退款”。
Independent Test:以不同权限账号访问列表、Secret 详情、保存、启停、订单查询和退款,权限与状态门禁正确。
0110。payType=3 订单与新 LINE Pay 订单共存。state=1,以及多门店父单包含不同 mdId。ruoyi-system只承载实体、Mapper XML、Service 和数据库条件更新,不依赖 LINE Pay HTTP 客户端,不导入 com.ruoyi.app.*:
pos_store_line_pay:门店凭证的不可变版本。pos_order_line_payment:每次 LINE Request/Confirm 支付尝试的规范状态。pos_order_line_refund:每笔已付款交易最多一条全额退款状态。payment_gateway_log:LINE Pay 原始交互与结果码的追加日志。ruoyi-adminLinePayClient:v4 HTTP、精确签名、超时和 DTO 解析。LinePayService:凭证探测、创建、回跳登记、Confirm、Retrieve、Check、退款及状态机。LinePayController:App 创建/查询接口和匿名回跳页。PosStoreLinePayController:平台门店凭证管理。LinePayReconcileTask:独立主动查询与恢复任务;只调用 Service,不调用 Controller。foodie-admin-vuesrc/views/mendian/storePayment/index.vue 保留 OMG Tab,把 LINE Pay 占位内容替换为独立 LinePayTab。src/api/chanting/storeLinePay.js。src/api/language/language.zh_CN.jssrc/api/language/language.zh_TW.jssrc/api/language/language.en_US.jssrc/api/language/language.vi.jsstoreLinePay 嵌套对象中,使用有意义的英文驼峰名称。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;失败者重新读取当前版本。
pos_order_line_payment只保存支付状态事实和恢复调度字段,不保存每次网关 returnCode 或整段原始响应。
关键字段:
| 字段 | 约束与含义 |
|---|---|
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、所有 UNKNOWN 和 MANUAL_REVIEW 继续占用 active_dd_id;只有网关明确 0121、0122 或明确无副作用失败才能释放。支付一旦 PAID 永不改写为退款状态。
禁止使用
UNIQUE(dd_id,is_active)并把历史行写成0,因为这会导致同一订单只能保存一条历史失效记录。DDL 使用可空active_dd_id唯一索引或等价生成列。
pos_order_line_refund退款独立保存,不污染支付状态。
关键字段:id、payment_id(唯一,保证全额退款只创建一次)、refund_transaction_id(字符串、可空唯一)、amount、status、version、恢复调度/租约字段、refund_time/create_time/update_time。
退款状态:
CREATED
PROCESSING
UNKNOWN
RETRY_WAIT
REFUNDED
FAILED
MANUAL_REVIEW
UNKNOWN 不得直接再次 Refund。仅官方明确可重试的 1900、1902、1999 可进入受控 RETRY_WAIT;其他明确失败进入 FAILED 或人工核对。
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。POST /v4/payments/request
GET /v4/payments/requests/{transactionId}/check
POST /v4/payments/{transactionId}/confirm
GET /v4/payments
POST /v4/payments/{transactionId}/refund
https://sandbox-api-pay.line.me,不可由请求参数控制。payType="3" 只属于本项目,绝不发送给 LINE。LINE 的 options.payment.payType 使用 NORMAL 或省略。options.payment.capture=true。Request 显式发送根级:
{
"redirectUrls": {
"confirmUrl": "https://foodieapi.waimai-paotui.com/pay/line/confirm",
"cancelUrl": "https://foodieapi.waimai-paotui.com/pay/line/cancel",
"confirmUrlType": "CLIENT",
"appPackageName": "com.twanmsdyh.app"
}
}
Request 的 amount 必须等于 packages 金额之和,每个 package 金额必须等于 products 的 price × quantity 之和。首版使用一个服务端生成的 package/product,金额等于订单应收整数金额。
Refund 为全额退款时省略 refundAmount;Refund 请求没有 currency 字段。
请求头:
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。
returnCode=0000 只表示查询成功,不等于已付款。必须在 info[] 中找到唯一匹配本地 line_order_id + transaction_id + currency 的记录,并确认:
transactionType=PAYMENTpayStatus=CAPTUREcredential_id 相符AUTHORIZATION 不是已结算。空数组、多条歧义或字段不匹配全部进入未知/人工核对。退款事实同时检查 refundList,不能只根据原 PAYMENT 仍为 CAPTURE 判断“未退款”。
ddId,不混用 pos_order.id、父单号和 LINE orderId。pos_order;多门店父单直接返回不支持 LINE Pay。state=1 仍可支付;金额为正整数 TWD。mdId 取得当前启用凭证版本。payType=3;OMG create 必须要求订单当前 payType=7。create 不允许临时切换支付渠道。pay:create:<ddId> Redisson 分布式锁内重新读取订单,并以 pay_status=0、非终态、pay_type=目标渠道 作为数据库条件门禁。锁获取失败直接返回处理中,不降级为无锁创建。现有 OMG create 只做该最小校验/锁调整,不改 OMG 表或日志。REQUESTING + line_order_id + credential_id + active_dd_id,再调用外部 Request。transaction_id/payment_url 并进入 WAITING_AUTH。超时、响应丢失或落库失败进入 REQUEST_UNKNOWN,由 Retrieve 按 line_order_id 恢复。重复创建规则:
WAITING_AUTH:查询现有尝试后返回同一有效 paymentUrl。READY_CONFIRM/CONFIRMING/UNKNOWN:返回“处理中”,不创建新尝试。PAID:直接返回已支付。CANCELLED_OR_EXPIRED/FAILED:可生成新的永久唯一 line_order_id。LINE 自动在 confirm URL 追加 orderId(即本地 line_order_id)和 transactionId。cancel URL 的两项参数可能缺失。两个 GET 都无签名、可伪造,不是最终资金事实。
/confirm:匹配本地流水并 CAS 登记 READY_CONFIRM,将 next_reconcile_at 提前,然后快速返回 HTML;不在浏览器请求内同步调用 Confirm。/cancel:只记本地事件并触发尽快 Check,不直接标记取消,不取消外卖订单。页面从已匹配的本地流水取得 ddId,构造固定 Scheme:
com.twanmsdyh.app://payment/result?orderId=<URL-encoded ddId>
页面加载后尝试一次 Scheme,并提供手动“打开 App”按钮;无法唤起时页面继续保留安全提示。
固定 Scheme 不接受请求参数指定跳转目标,避免开放重定向。
HTML/JS/URL 参数正确转义,内联脚本使用每响应 CSP nonce;响应使用 text/html;charset=UTF-8、Cache-Control: no-store、Referrer-Policy: no-referrer、X-Content-Type-Options: nosniff 和限制性 CSP/frame-ancestors 'none'。
执行者先以 CAS 和行租约把状态变为 CONFIRMING,再调用外部 Confirm:
active_dd_id、订单状态、门店、credential_id、line_order_id、transaction_id、金额和币种。AUTH_DONE_ORDER_CANCELLED 并等待网关过期终态。0000 后验证响应并在本地事实事务中把支付置为 PAID。1198、1199、9000 或本地落库未知时置为 CONFIRM_UNKNOWN,只能 Retrieve 恢复。pos_order.pay_type=3,pay_status=1,并只触发一次现有履约/推送副作用。堂食初始 state=1 也允许核销。state=4,仍先记录 PAID 和订单已付款事实,并在同一事务插入唯一退款意图;不触发履约。商家接单以及仍有效的 /setorderuzt 必须阻止 payType=3,payStatus=0 的订单进入履约,避免未付款接单。
恢复时优先 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。
userType/shId/mdId 规则验证订单归属。insert-if-absent(payment_id) 创建 CREATED 退款;若尚未支付,迟到支付落账事务负责补建。PROCESSING,先 Retrieve 排除已经退款,再调用一次省略 refundAmount 的全额 Refund。0000 且返回 refundTransactionId 后置 REFUNDED,再更新订单 payStatus=2 和既有退款后状态/积分。UNKNOWN;通过原 PAYMENT 的 refundList 或 refundTransactionId Retrieve 恢复,禁止直接重发 Refund。LinePayReconcileTask 位于 ruoyi-admin/src/main/java/com/ruoyi/app/task,默认每 60 秒启动一轮:
next_reconcile_at <= now 的非终态支付/退款,默认批量上限 20,并设置单轮时间预算;一笔失败不影响其他记录。lease_owner/lease_until/version CAS claim。即使全局锁失效或人工操作并发,也只有一个副作用执行者。(status,next_reconcile_at,id);按状态退避并递增 reconcile_count。MANUAL_REVIEW 并保留 active_dd_id。MANUAL_REVIEW,绝不释放活跃键或自动重试副作用。以上周期、批量和期限使用服务端配置,可调但不得由客户端请求控制。
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 orderId、transactionId。cancel:@Anonymous,显式 @RequestParam(required=false) 接收可能缺失的 orderId、transactionId。创建响应包含:ddId、lineOrderId、字符串 transactionId、paymentUrl、规范支付状态、reusedAttempt。查询响应包含订单支付状态、LINE 规范支付状态、退款状态和更新时间;不以 Scheme 或回跳参数作为结果。
门店管理至少提供列表、详情、保存并验证凭证、启停当前版本。订单管理至少提供查询核实和全额退款。
权限拆分:
chanting:storePayment:list
chanting:storeLinePay:list
chanting:storeLinePay:query
chanting:storeLinePay:saveCredentials
chanting:storeLinePay:toggleEnable
system:order:linePayQuery
system:order:linePayRefund
现有支付管理菜单入口从仅 OMG 权限调整为公共 storePayment:list,避免只有 LINE 权限时进不了页面。所有 SQL 只写入 updatesql/sql.md。
payType="3" 定义为 LINE Pay 直连,并更新 PosOrder、OrderPositionInfo 等仍被有效链路引用的注释;废弃 ZaloPay 实体、Service、Controller、配置和历史字段语义不做增量修改,LINE Pay 使用独立配置前缀和独立流水识别。mdId 使用当前已启用且探测通过的门店凭证版本,金额固定取服务端订单的整数 TWD。payType=3 发送为 LINE API 的 options.payment.payType。line_order_id 和支付意图,并用唯一索引、分布式订单锁和数据库 CAS 保证重复/并发 create 不生成多个有效尝试。0000/0110/0121/0122/0123 映射推进状态。PAYMENT/CAPTURE 后记录已支付;Check、HTTP 200 和回跳到达均不是最终资金事实。refundAmount;退款未知时 MUST Retrieve,禁止盲目重复 Refund,只有证实退款后才更新订单已退款状态。credential_id;新凭证探测失败或未知不得覆盖当前版本,旧交易继续使用原版本。payment_gateway_log,原始结果码不塞入支付/退款业务表;该日志首版 MUST NOT 接管或迁移 OMG 日志。$t() 并同步简中、繁中、英文、越南文四份实际语言文件,key 使用 storeLinePay 下有意义的英文驼峰名称。ipn_log 和既有原始回调结构不变;只允许为防跨渠道并发,对 OMG create 增加相同订单级锁和渠道一致性门禁。payType=3 操作资金。updatesql/sql.md,不得由实现过程直接执行。ddId 调查询接口取得最终支付/退款状态,不得信任 URL 参数得出支付结果。payType=3 只有在存在 pos_order_line_payment 且标识匹配时才允许 LINE 查询或退款。transactionId 在 Java/JSON/前端全程保持字符串。refundAmount。state=0 和堂食 state=1 均可正确核销;未付 LINE 订单不得接单。payType=3 不触发 LINE 查询或退款。shId/mdId 范围订单;平台各 LINE 权限独立生效。$t()。paymentUrl.web,完成 Web 收银台认证。0000/0110/0121/0122/0123 映射和 Retrieve 二次核实。Sandbox 官方不支持 App payment URL,也不能模拟 EPI;以下必须在生产启用前单独完成:
/payment/result,打开后带 token 查询服务端最终状态。appPackageName 和 CLIENT 回跳行为。paymentProvider 的 TSP/EPI 兼容。pos_order.dd_id 是 App 与支付接口使用的业务订单号;LINE 的 orderId 始终指独立 line_order_id,两者不可混用。https://foodieapi.waimai-paotui.com 已具备有效 HTTPS/TLS。