# LINE Pay Online / Offline API v4 Implementation Plan > **For agentic workers:** 按 `tasks.md` 顺序逐项执行;每个实现任务先补失败测试,再写最小实现并运行对应验证。不得跳过状态机 CAS、外部调用前持久化 intent 或最终 `git diff` 检查。 **Goal:** 在保留现有 LINE Pay Online API v4 能力的同时,为商家端创建的单门店订单增加 Offline API v4 My Code 扫码付款、主动查单和全额退款后端能力。 **Architecture:** `ruoyi-system` 只承载实体、MyBatis Mapper 和持久化状态机;`ruoyi-admin` 承载 LINE HTTP/HMAC、订单编排、回跳页面、定时任务和 Controller。Online 与 Offline 复用同一支付尝试、支付事实、退款事实、门店凭证和订单级活跃门禁,通过持久化的 `payment_mode` 选择网关端点,通过 `order_source` 限制商家扫码订单。所有有资金副作用的请求都采用“本地先提交 intent/claim → 外部 HTTP → 独立本地 CAS 落事实”,不依赖跨网络事务。 **Tech Stack:** Java 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Apache HttpClient 4、fastjson2、Redisson、JUnit 5/Mockito;现有 Online 管理功能仍使用 Vue 2/Element UI,本次 Offline 增量不修改前端。 --- ## Summary LINE Pay 普通付款省略未被当前公开契约保证的 `payType/confirmUrlType/appPackageName`,仅显式 `capture=true` 并使用默认浏览器回跳。Request 成功只代表已创建付款请求;回跳快速登记后返回内嵌安全中间页,Confirm 由可恢复服务异步完成。任务每分钟按行租约扫描,先 Retrieve,再按需 Check;只有 Check `0110` 且本地订单仍可付款时才能 Confirm。支付经严格匹配的 Retrieve/Confirm 证实后,支付行永久保持 `PAID`,退款事实只写退款表。 Offline 增量只服务 `/system/orderShOprate/createOrder` 新建并持久化为 `MERCHANT` 的单门店订单。商家端提交 `ddId + oneTimeKey` 后,后端在现有订单级支付锁内创建 `payment_mode=OFFLINE` 的永久支付尝试,调用 `/v4/payments/oneTimeKeys/pay` 自动请款;等待认证或结果未知时只按 `lineOrderId` 调 `/v4/payments/orders/{orderId}/check`,绝不重复发送 Pay 或重用 My Code。Online/Offline 退款分别按 `transactionId/lineOrderId` 分派,公共资金事实与通知副作用继续使用现有 CAS 和唯一约束。 ## Technical Context **Language/Version**: Java 21;Vue 2 **Primary Dependencies**: Spring Boot、MyBatis、MyBatis-Plus、Apache HttpClient 4.5.14、fastjson2、Redisson、Element UI **Storage**: MySQL;保留 4 张 LINE 专用表,新增 `pos_order.order_source` 和 `pos_order_line_payment.payment_mode`,只记录到 `updatesql/sql.md` **Testing**: Maven Surefire + JUnit 5/Mockito;Offline 增量只执行后端定向测试、模块构建和完整 LINE 回归 **Target Platform**: Windows 开发;Spring Boot 多实例部署;LINE Pay Sandbox/Production **Project Type**: 多模块后端 + 独立平台 Vue 项目 **Performance Goals**: 商家状态查询只读本地数据库;Offline Pay Read Timeout 不少于 40 秒,Check/Refund 不少于 20 秒;定时任务单轮最多 20 笔并走 `(status,next_reconcile_at,id)` **Constraints**: 仅修改后端;不修改 OMG 表结构或迁移 OMG 日志;不修改废弃 ZaloPay/PayController/TestTask/OrderAppeal;`oneTimeKey` 不落库、不进入日志/异常/响应;支付/退款副作用未知时不得盲重试 **Scale/Scope**: 门店级凭证;商家端新建的单门店餐饮订单;每订单多次历史尝试但 Online/Offline 合计最多一条阻断性尝试;只支持自动请款和全额退款 ## Constitution Check `.specify/memory/constitution.md` 尚未定义项目条款,因此以下仓库规则作为强制门禁: - [x] 依赖方向保持 `ruoyi-admin -> ruoyi-system`;LINE HTTP 集成只放 `ruoyi-admin`。 - [x] Controller 使用明确 DTO、`@RequestBody/@RequestParam/@RequestHeader/@PathVariable`,不新增 Map 入参或 Bean Validation。 - [x] 所有 DDL/权限 SQL 只追加到 `updatesql/sql.md`,不直接执行数据库变更。 - [x] 不修改废弃支付与任务代码;仅在当前有效订单入口接入支付/退款门禁。 - [x] 商家接口使用 token 校验身份及 `shId/mdId` 门店归属;用户端和历史订单不得推断为商家来源。 - [x] `oneTimeKey` 只在 Controller 至网关调用链内存中短暂存在,所有审计和异常路径执行脱敏。 - [x] Offline 增量只修改 `foodie_server`,不修改 uni-app、`foodie-store` 或 `foodie-admin-vue`。 - [x] 平台新增文本四语同步,保留 Vue 文件 CRLF。 - [x] 修复/功能均先测试再实现,交付前编译、定向测试、前端校验和 diff 审核。 ## Project Structure ### Documentation ```text specs/019-line-pay/ ├── spec.md ├── plan.md ├── research.md ├── data-model.md ├── quickstart.md ├── offline-merchant-scan-design.md ├── tasks.md ├── contracts/ │ └── api.md └── checklists/ └── requirements.md ``` ### Source Code ```text foodie_server/ ├── ruoyi-system/src/main/java/com/ruoyi/system/ │ ├── domain/{PosStoreLinePay,PosOrderLinePayment,PosOrderLineRefund,PaymentGatewayLog}.java │ ├── domain/PosOrder.java │ ├── domain/dto/{StoreLinePayCredentialDto,StoreLinePayToggleDto}.java │ ├── domain/vo/PosStoreLinePayVo.java │ ├── mapper/*Line*.java │ └── service/{I*,impl/*}.java ├── ruoyi-system/src/main/resources/mapper/chanting/*Line*.xml ├── ruoyi-admin/src/main/java/com/ruoyi/app/ │ ├── utils/linepay/{LinePayProperties,LinePaySigner,LinePayHttpTransport,ApacheLinePayHttpTransport,LinePayClient}.java │ ├── pay/{LinePayController,LinePayService,LinePayOfflineService,LinePayGatewayAuditService,PaymentCreateGuardService}.java │ ├── pay/dto/{LinePayOfflinePayRequest,LinePayOfflineResult,*LinePay*}.java │ ├── order/{PosOrderShOprateController,PosOrderLinePayOfflineController}.java │ ├── mendian/PosStoreLinePayController.java │ └── task/LinePayReconcileTask.java ├── ruoyi-admin/src/test/java/com/ruoyi/app/{utils/linepay,pay,order,task}/*Test.java ├── ruoyi-system/src/test/java/com/ruoyi/system/service/impl/*Line*Test.java └── updatesql/sql.md foodie-admin-vue/ └── src/ ├── api/chanting/storeLinePay.js ├── api/system/order.js ├── views/mendian/storePayment/{index.vue,components/LinePayTab.vue} ├── views/system/order/index.vue └── api/language/language.{zh_CN,zh_TW,en_US,vi}.js ``` ## Component Design ### Persistence boundary (`ruoyi-system`) - `IPosStoreLinePayService` 负责版本号分配、验证成功后的当前版本原子切换、启停和列表/详情。 - `IPosOrderLinePaymentService` 负责创建 `REQUESTING`、稳定键查询、App 选择规则、CAS 状态推进、活跃键释放、扫描和行租约。 - `PosOrder`、`PosOrderMapper.xml` 新增 `order_source`,默认 `USER`;仅商家创建入口显式写 `MERCHANT`,不回填历史订单。 - `PosOrderLinePayment`、Mapper 和 Service 新增非空 `payment_mode`,既有行默认 `ONLINE`;所有新尝试显式写模式,查询、恢复和退款不得根据 URL 或交易号形态猜测模式。 - `IPosOrderLineRefundService` 负责 `UNIQUE(payment_id)` 的 insert-if-absent、CAS claim、扫描和退款事实。 - `IPaymentGatewayLogService` 只追加审计数据;日志不是状态机事实,日志写入失败不回滚资金事实。 - `PosOrderMapper` 新增条件更新:只有未付款、合法订单态且当前 `pay_type` 与目标渠道一致时,才允许领取支付渠道或更新支付 URL。 ### LINE gateway boundary (`ruoyi-admin`) - `LinePaySigner` 严格按 `channelSecret + URI + body/query + nonce` 计算 Base64 HMAC-SHA256;GET 使用最终原始 query 字符串,POST 使用最终 UTF-8 JSON 字节。 - `LinePayClient` 保留 Online `request/check/confirm/retrieve/refund`,新增 Offline `payOffline/checkOffline/refundOfflineFull`;交易号始终为字符串。Online Request 10 秒、Offline Pay 40 秒、Check/Retrieve/Refund 20 秒、Confirm 40 秒。 - `LinePayHttpTransport` 隔离实际 Apache HttpClient,便于不联网测试精确 method/URI/body/header。 - `LinePayGatewayAuditService` 记录 correlationId、方向、action、HTTP/LINE 结果和 payload;Offline REQUEST 在进入审计边界前移除或替换 `oneTimeKey` 为 ``,异常对象和响应也不得携带原值。 ### Orchestration boundary (`ruoyi-admin`) - `PaymentCreateGuardService` 使用共享 `pay:create:` Redisson watchdog 锁和订单条件更新,LINE 与 OMG create 都经过同一门禁;OMG 仅做这处最小代码调整。 - `LinePayService.create` 在锁内校验用户、订单、单门店、金额、当前凭证和历史 payType=3 归属,然后复用活跃尝试或先提交新 `REQUESTING`。Request HTTP 在事务外执行,结果以 CAS 落为 `WAITING_AUTH` 或 `REQUEST_UNKNOWN`。 - `LinePayService.onConfirmRedirect` 只按 `line_order_id` 登记并尝试把行推进到可处理状态,不同步等待 Confirm;Controller 返回安全 HTML。 - `LinePayService.reconcilePayment` 始终优先 Retrieve;无付款事实才 Check。副作用调用前先 CAS claim,超时进入 UNKNOWN,后续只读恢复。 - `LinePayService.applyPaidFact` 在一笔本地事务内写 `PAID`、锁定订单并更新 `pay_status=1`;订单已取消则 insert-if-absent 创建退款 intent。 - `LinePayService.requestFullRefund` 只针对 `PAID` 行,省略 `refundAmount`;成功后退款表为 `REFUNDED` 且订单 `pay_status=2`,支付表保持 `PAID`。 - `LinePayReconcileTask` 使用进程级 watchdog 锁减少重复扫描,但每行仍通过 `lease_owner/lease_until/version` 领取;失败一笔不终止整批。 - `LinePayOfflineService` 校验商家、订单来源、门店归属、单门店、订单状态、金额、`payType=3` 和凭证,在共享支付锁内创建/复用 `OFFLINE` 尝试;Pay 响应不明确后只调 Check。 - `LinePayService.reconcilePayment` 按 `payment_mode` 分派 Online Retrieve/Check/Confirm 或 Offline Check;公共的 `LinePayFactService.applyPaidFact` 继续负责至多一次落账、结算和通知。 - `LinePayRefundService` 按 `payment_mode` 选择 Online `transactionId` Refund 或 Offline `lineOrderId` Refund;退款 UNKNOWN 仍只用现有 Retrieve/refundList 证据恢复。 ### Platform and App boundary - `/pay/line/create`、`/query` 使用 `{ddId}` DTO 和现有 `@Auth + @Anonymous + token header` 组合;confirm/cancel 仅为匿名 GET 回跳。 - HTML 页面由 Controller 自包含返回,不依赖单独服务器部署;固定 App Scheme,不接受客户端 redirect 参数,并设置 no-store/no-referrer/CSP/frame 限制。 - 平台凭证页可查看当前版本详情、验证并切换新版本、启停;普通列表不携带 Secret,拥有详情权限的页面按用户决定可回显。 - 订单平台页显示 LINE 支付/退款状态,并提供人工查询与全额退款;资金动作使用独立权限。 ## Offline Incremental Design (2026-08-18) ### Merchant order source and authorization - `/system/orderShOprate/createOrder` 在落父子订单前验证 token 对应有效商家、`items` 恰好一个门店包,并按项目规则校验门店归属:`userType=1/3` 同时要求 `PosOrder.shId=userId` 和目标 `PosStore.userId=userId`,其他摊位商家使用 `PosOrder.mdId=InfoUser.storeId`。支付前按同一规则重查订单与门店,不能把本人 `shId` 和其他商家的 `mdId` 组合使用。 - 所有通过该入口创建的子订单显式写 `order_source=MERCHANT`;用户端和历史数据使用数据库默认 `USER`。扫码接口不接受客户端传入来源、金额、门店、币种、凭证或 LINE orderId。 - 独立 `PosOrderLinePayOfflineController` 暴露付款和状态接口,均使用 `@RequestHeader String token`;POST 使用明确 `@RequestBody` DTO,GET 使用显式 `@RequestParam`,业务校验通过 `MessageUtils.message(...)` 返回。 ### Offline payment and local status - `oneTimeKey` 只校验台湾 18 位数字格式并传给一次 `payOffline` 调用,不保存到 DTO 之外的对象、数据库、普通日志、异常文本、Controller 响应或 `payment_gateway_log.payload`。 - Pay `0000` 只有在 `orderId`、字符串 `transactionId`、`payInfo` 合计和非空 `paymentProvider` 全部匹配时才能写入 `PAID`;`paymentProvider` 原值保存,已知 `TSP/EPI` 但不做封闭枚举。 - `1145/1169/AUTH_READY/WAITING_AUTH` 对商家返回 `AUTH_REQUIRED`;Pay 超时、响应丢失、未识别返回码、重复请求待核实及 `REQUEST_UNKNOWN` 返回 `PROCESSING`;`COMPLETE/CANCEL/FAIL` 返回 `PAID/CANCELLED/FAILED`。只有白名单内明确且无资金副作用的 Pay 拒绝码才能释放活跃键,Check 的未识别返回码始终保留占用并只读重查。 - `AUTH_REQUIRED`、`PROCESSING`、`PAID`、`AMOUNT_MISMATCH` 和 `MANUAL_REVIEW` 均阻止再次扫码。只有明确 `CANCEL/FAIL` 释放活跃键;金额不一致退款完成后原订单仍关闭支付,商家需要新建订单,避免同一订单同时承载异常退款和新付款。 ### Recovery and refund - 首次进入 `WAITING_AUTH` 时将创建尝试的 24 小时未知截止改写为 30 分钟认证截止,后续查询保留原截止而不顺延;`REQUEST_UNKNOWN` 和退款 UNKNOWN 复用 24 小时截止。截止前只读 Check/Retrieve,截止后进入 `MANUAL_REVIEW` 并保留活跃占用。 - Offline Check 使用永久 `lineOrderId`;任何超时、不可解析响应、`1152/1172/1198/1199/190X/9000` 或重复请求可能性都不得重发 Pay。 - `payInfo` 金额不一致时将真实交易标识和实际扣款额保存到 `captured_amount`,在同一事务内置 `AMOUNT_MISMATCH` 并 insert-if-absent 创建以实际扣款额为金额的全额退款;禁止正常履约,退款证据明确前不得释放支付占用。 - 所有 Offline 状态推进检查 CAS 影响行数;失败时重读持久化状态。唯一键冲突复用已有尝试并直接返回,禁止再次调用 Pay。 - Offline 全额退款使用 `/v4/payments/orders/{lineOrderId}/refund` 且省略 `refundAmount`;Online 继续使用 `/v4/payments/{transactionId}/refund`,唯一分派依据是持久化 `payment_mode`。 ### Security review gates - 服务端只信任 token 身份、数据库订单金额/门店/来源/状态和已启用凭证;客户端的 `ddId` 与 `oneTimeKey` 均在外部调用前做白名单校验和归属检查。 - 网关 URL 仍受 `LinePayClient.baseUrl()` allowlist 限制,HMAC 使用最终 URI 和 UTF-8 JSON;不发送设备请求头、redirect URL、Capture 或 Void。 - 商家状态接口只返回规范状态和必要交易上下文,不返回 `oneTimeKey`、Channel Secret、HMAC、网关原始请求体、堆栈或内部租约字段。 ## Error and Recovery Policy | 场景 | 本地处理 | 后续动作 | |---|---|---| | Request 超时/响应丢失 | `REQUEST_UNKNOWN`,保留活跃键 | Retrieve by `line_order_id`,必要时 Check | | Check `0000` | 保持等待 | 退避后再查 | | Check `0110` | 本地门禁通过后 CAS `CONFIRMING` | Confirm;超时转 `CONFIRM_UNKNOWN` | | Check `0121/0122` | 先 Retrieve 排除已付款 | 明确未付款才终止并释放活跃键 | | Check `0123` | 不直接判已付 | Retrieve 验证唯一 `PAYMENT + CAPTURE` | | Confirm 非明确结果/超时 | `CONFIRM_UNKNOWN` | 只读 Retrieve/Check,不盲 Confirm | | 取消后迟到付款 | 支付仍写 `PAID` | 唯一退款 intent + 自动全退 | | Refund 超时/未知 | `UNKNOWN` | Retrieve 原支付并检查 `refundList` | | 到达恢复截止仍未知 | `MANUAL_REVIEW` | 停止自动副作用,平台人工处理 | | Offline Pay `1145/1169` 或 Check `AUTH_READY` | `WAITING_AUTH` | 返回 `AUTH_REQUIRED`,按 lineOrderId 继续 Check | | Offline Pay 超时/响应丢失 | `REQUEST_UNKNOWN` | 返回 `PROCESSING`,只读 Check,禁止重发 Pay | | Offline Pay/Check 未识别返回码 | 保留阻断状态 | 返回 `PROCESSING` 或继续只读 Check,禁止按失败释放或重发 Pay | | Offline Check `CANCEL/FAIL` | `CANCELLED_OR_EXPIRED/FAILED` | 释放活跃键,客户生成新 My Code 后可重试 | | Offline `paymentProvider` 缺失 | `MANUAL_REVIEW` | 不触发履约,不自动释放占用 | | Offline 金额不一致 | `AMOUNT_MISMATCH` | 持久化实际扣款额并以该金额创建唯一全额退款,原订单不再支付 | ## Implementation Order 1. 扩展 `pos_order.order_source` 与 `pos_order_line_payment.payment_mode` 的 SQL、Entity、Mapper、Service 和兼容测试;既有 Online 行和历史订单使用默认值。 2. 为商家下单入口增加身份、门店归属、单门店校验和 `MERCHANT` 来源写入,覆盖普通商家、夜市商家及其他摊位商家规则。 3. 扩展 `LinePayClient` 的 Offline Pay/Check/Refund 契约、超时、HMAC 和响应解析测试,并实现 `oneTimeKey` 全链路脱敏。 4. 新增商家 Offline Controller/DTO/Service,完成创建或复用尝试、状态映射、并发门禁和金额/渠道事实核对。 5. 让定时恢复和退款按 `payment_mode` 分派,覆盖认证等待、未知结果、金额不一致、取消竞态和 Online 回归。 6. 更新 SQL 与 Spec Kit 产物后,统一运行 JDK 21 定向测试、模块构建和 LINE Pay 完整回归;不运行前端构建,因为本增量不修改前端。 ## Verification Gates - `mvn -pl ruoyi-system -am -Dtest='*Line*Test' -Dsurefire.failIfNoSpecifiedTests=false test` - `mvn -pl ruoyi-admin -am -Dtest='*LinePay*Test,OrderLifecycleServiceTest,OmgPayControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test` - `mvn -pl ruoyi-admin -am -Dtest='*LinePayOffline*Test,PosOrderShOprateControllerTest,*LinePay*Test,OrderLifecycleServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test` - `mvn -pl ruoyi-admin -am -DskipTests package` - Offline 增量不运行 `foodie-admin-vue` 校验;若后续实际修改平台前端,再恢复 `npm run lint` 和 `npm run build:prod`。 - `git diff --check`、`git status --short`、逐文件核对换行/编码、确认未触碰废弃代码和用户已有 OMG SQL。 ## Offline Requirement Coverage | Requirement | Planned implementation and verification | |---|---| | FR-OFF-001 | Offline 增量仅涉及 `foodie_server`;构建与差异检查确认无前端文件 | | FR-OFF-002 | `payment_mode` DDL、Entity/Mapper/Service、模式分派和 Online 默认值回归 | | FR-OFF-003 | `order_source` DDL、商家入口显式写入、用户/历史默认 `USER` 测试 | | FR-OFF-004 | 商家身份、`shId/mdId` 归属、单门店和支付前二次门禁测试 | | FR-OFF-005 | `LinePayOfflinePayRequest` 只含 `ddId/oneTimeKey`,其余请求事实由服务端构造 | | FR-OFF-006 | `LinePayClient.payOffline` 自动请款契约,不实现 Capture/Void/redirect/device headers | | FR-OFF-007 | HTTP transport 契约测试断言 Pay 40 秒、Check/Refund 20 秒 | | FR-OFF-008 | `REQUEST_UNKNOWN` 只读 Check、禁止重发 Pay 与复用 My Code 的恢复测试 | | FR-OFF-009 | 1145/1169 和 AUTH_READY/COMPLETE/CANCEL/FAIL 的本地/接口状态映射测试 | | FR-OFF-010 | orderId、transactionId、payInfo 和非空 paymentProvider 的严格事实核对测试 | | FR-OFF-011 | `AMOUNT_MISMATCH`、唯一全退意图、禁止履约和关闭原订单支付测试 | | FR-OFF-012 | Online/Offline 共享 `active_dd_id` 与 Redisson 订单锁的并发测试 | | FR-OFF-013 | Refund 按 `payment_mode` 使用 transactionId/lineOrderId 的双模式回归 | | FR-OFF-014 | Controller、Client、审计和异常路径的 oneTimeKey 不落库/不泄漏测试 | | FR-OFF-015 | 复用现有门店凭证;quickstart 记录生产 Offline 权限前置验收 | | FR-OFF-016 | DDL 仅追加 `updatesql/sql.md`,验证过程不执行迁移 | ## Complexity Tracking | Decision | Why Needed | Simpler Alternative Rejected Because | |---|---|---| | 订单 1:N 支付尝试 + 可空唯一活跃键 | 保留每次网关 orderId/transactionId/凭证与未知状态,避免迟到回跳污染新支付 | 覆盖单行会丢失恢复和退款所需键值 | | 不可变凭证版本 + payment.credential_id | 轮换后历史交易仍能找到原渠道身份 | 一店一行覆盖会让旧交易失去凭证归属 | | 支付与退款分表 | `PAID` 是资金事实,退款是另一事实与状态机 | 在支付状态写 REFUNDED 会掩盖曾经扣款 | | durable intent + 外部调用 + CAS | 数据库事务无法覆盖 LINE 网络副作用 | 单个 `@Transactional` 无法修复响应丢失和本地提交失败 | | 同表保存 Online/Offline 并增加 `payment_mode` | 复用支付事实、退款、凭证、审计和恢复,同时可靠选择不同端点 | 新建 Offline 专表会复制状态机;根据 URL/交易号猜测模式不可靠 | | `order_source` 使用数据库默认 `USER` | 只有新商家端订单可扫码,历史订单不被误判 | 根据 userId、时间或入口日志回填来源不可审计 | | 金额不一致订单退款后仍关闭支付 | 避免一张订单同时存在异常退款和后续新付款,简化结算与对账 | 自动释放并重扫会扩大异常资金状态组合 | ## Post-Design Constitution Re-check `.specify/memory/constitution.md` 仍为未填充模板,没有可执行的额外原则。按仓库规则复核后,设计保持 `ruoyi-admin -> ruoyi-system` 依赖方向、明确 Controller DTO、商家门店归属鉴权、SQL 只写 `updatesql/sql.md`、不修改废弃支付代码、Offline 后端单一范围和敏感数据脱敏;未发现需要例外说明的门禁冲突。