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 增量不修改前端。
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 和唯一约束。
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 合计最多一条阻断性尝试;只支持自动请款和全额退款
.specify/memory/constitution.md 尚未定义项目条款,因此以下仓库规则作为强制门禁:
ruoyi-admin -> ruoyi-system;LINE HTTP 集成只放 ruoyi-admin。@RequestBody/@RequestParam/@RequestHeader/@PathVariable,不新增 Map 入参或 Bean Validation。updatesql/sql.md,不直接执行数据库变更。shId/mdId 门店归属;用户端和历史订单不得推断为商家来源。oneTimeKey 只在 Controller 至网关调用链内存中短暂存在,所有审计和异常路径执行脱敏。foodie_server,不修改 uni-app、foodie-store 或 foodie-admin-vue。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
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
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。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>,异常对象和响应也不得携带原值。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 证据恢复。/pay/line/create、/query 使用 {ddId} DTO 和现有 @Auth + @Anonymous + token header 组合;confirm/cancel 仅为匿名 GET 回跳。/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(...) 返回。oneTimeKey 只校验台湾 18 位数字格式并传给一次 payOffline 调用,不保存到 DTO 之外的对象、数据库、普通日志、异常文本、Controller 响应或 payment_gateway_log.payload。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 释放活跃键;金额不一致退款完成后原订单仍关闭支付,商家需要新建订单,避免同一订单同时承载异常退款和新付款。WAITING_AUTH 时将创建尝试的 24 小时未知截止改写为 30 分钟认证截止,后续查询保留原截止而不顺延;REQUEST_UNKNOWN 和退款 UNKNOWN 复用 24 小时截止。截止前只读 Check/Retrieve,截止后进入 MANUAL_REVIEW 并保留活跃占用。lineOrderId;任何超时、不可解析响应、1152/1172/1198/1199/190X/9000 或重复请求可能性都不得重发 Pay。payInfo 金额不一致时将真实交易标识和实际扣款额保存到 captured_amount,在同一事务内置 AMOUNT_MISMATCH 并 insert-if-absent 创建以实际扣款额为金额的全额退款;禁止正常履约,退款证据明确前不得释放支付占用。/v4/payments/orders/{lineOrderId}/refund 且省略 refundAmount;Online 继续使用 /v4/payments/{transactionId}/refund,唯一分派依据是持久化 payment_mode。ddId 与 oneTimeKey 均在外部调用前做白名单校验和归属检查。LinePayClient.baseUrl() allowlist 限制,HMAC 使用最终 URI 和 UTF-8 JSON;不发送设备请求头、redirect URL、Capture 或 Void。oneTimeKey、Channel Secret、HMAC、网关原始请求体、堆栈或内部租约字段。| 场景 | 本地处理 | 后续动作 |
|---|---|---|
| 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 |
持久化实际扣款额并以该金额创建唯一全额退款,原订单不再支付 |
pos_order.order_source 与 pos_order_line_payment.payment_mode 的 SQL、Entity、Mapper、Service 和兼容测试;既有 Online 行和历史订单使用默认值。MERCHANT 来源写入,覆盖普通商家、夜市商家及其他摊位商家规则。LinePayClient 的 Offline Pay/Check/Refund 契约、超时、HMAC 和响应解析测试,并实现 oneTimeKey 全链路脱敏。payment_mode 分派,覆盖认证等待、未知结果、金额不一致、取消竞态和 Online 回归。mvn -pl ruoyi-system -am -Dtest='*Line*Test' -Dsurefire.failIfNoSpecifiedTests=false testmvn -pl ruoyi-admin -am -Dtest='*LinePay*Test,OrderLifecycleServiceTest,OmgPayControllerTest' -Dsurefire.failIfNoSpecifiedTests=false testmvn -pl ruoyi-admin -am -Dtest='*LinePayOffline*Test,PosOrderShOprateControllerTest,*LinePay*Test,OrderLifecycleServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false testmvn -pl ruoyi-admin -am -DskipTests packagefoodie-admin-vue 校验;若后续实际修改平台前端,再恢复 npm run lint 和 npm run build:prod。git diff --check、git status --short、逐文件核对换行/编码、确认未触碰废弃代码和用户已有 OMG SQL。| 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,验证过程不执行迁移 |
| 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、时间或入口日志回填来源不可审计 |
| 金额不一致订单退款后仍关闭支付 | 避免一张订单同时存在异常退款和后续新付款,简化结算与对账 | 自动释放并重扫会扩大异常资金状态组合 |
.specify/memory/constitution.md 仍为未填充模板,没有可执行的额外原则。按仓库规则复核后,设计保持 ruoyi-admin -> ruoyi-system 依赖方向、明确 Controller DTO、商家门店归属鉴权、SQL 只写 updatesql/sql.md、不修改废弃支付代码、Offline 后端单一范围和敏感数据脱敏;未发现需要例外说明的门禁冲突。