# 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` 为 `<redacted>`，异常对象和响应也不得携带原值。

### Orchestration boundary (`ruoyi-admin`)

- `PaymentCreateGuardService` 使用共享 `pay:create:<ddId>` 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 后端单一范围和敏感数据脱敏；未发现需要例外说明的门禁冲突。
