Browse Source

功能:闪送接入骑手 LINE Pay 支付并完善取消退款

新增骑手凭证版本管理、接单后付款与可信到账门禁;完善取件前全额退款、迟到付款补偿和持久恢复,禁止取件后取消。同步注释、规格、迁移与接入文档,并记录提交信息使用中文的偏好。
qmj 1 tuần trước cách đây
mục cha
commit
cf59c0bed8
72 tập tin đã thay đổi với 1958 bổ sung và 39 xóa
  1. 1 0
      AGENTS.md
  2. 107 0
      docs/flash-delivery-line-pay-app-integration.md
  3. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java
  4. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashLinePayCreateRequest.java
  5. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashLinePayPaymentView.java
  6. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayAsyncService.java
  7. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayController.java
  8. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayEvidence.java
  9. 175 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayFactService.java
  10. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRefundService.java
  11. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayReturnController.java
  12. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayReturnPageRenderer.java
  13. 128 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayService.java
  14. 46 16
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  15. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java
  16. 53 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/RiderLinePayController.java
  17. 43 0
      ruoyi-admin/src/main/java/com/ruoyi/app/task/FlashLinePayReconcileTask.java
  18. 8 2
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayClient.java
  19. 10 1
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayProperties.java
  20. 3 0
      ruoyi-admin/src/main/resources/application.yml
  21. 10 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  22. 10 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  23. 10 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  24. 10 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  25. 10 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  26. 27 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayControllerTest.java
  27. 16 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayEvidenceTest.java
  28. 93 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayFactServiceTest.java
  29. 50 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRecoveryFairnessTest.java
  30. 40 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRefundServiceTest.java
  31. 52 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayServiceTest.java
  32. 57 5
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  33. 3 3
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  34. 5 3
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachineTest.java
  35. 32 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/RiderLinePayControllerTest.java
  36. 30 0
      ruoyi-admin/src/test/java/com/ruoyi/app/task/FlashLinePayReconcileTaskTest.java
  37. 9 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java
  38. 22 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoRiderLinePay.java
  39. 9 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/RiderLinePayCredentialDto.java
  40. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/RiderLinePayToggleDto.java
  41. 27 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryLinePayment.java
  42. 20 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryLineRefund.java
  43. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  44. 7 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/InfoRiderLinePayVo.java
  45. 14 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoRiderLinePayMapper.java
  46. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryLinePaymentMapper.java
  47. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryLineRefundMapper.java
  48. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  49. 15 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoRiderLinePayService.java
  50. 9 3
      ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java
  51. 78 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoRiderLinePayServiceImpl.java
  52. 15 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryLinePaymentMapper.xml
  53. 14 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryLineRefundMapper.xml
  54. 6 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  55. 27 0
      ruoyi-system/src/main/resources/mapper/infouser/InfoRiderLinePayMapper.xml
  56. 28 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/FlashLinePayMapperTest.java
  57. 16 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/InfoRiderLinePayMapperTest.java
  58. 10 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/PaymentMethodGateServiceTest.java
  59. 59 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/InfoRiderLinePayServiceTest.java
  60. 4 0
      specs/019-line-pay/plan.md
  61. 3 0
      specs/019-line-pay/quickstart.md
  62. 103 0
      specs/019-line-pay/rider-flash-online-plan.md
  63. 79 0
      specs/019-line-pay/rider-flash-online-spec.md
  64. 20 0
      specs/019-line-pay/spec.md
  65. 24 1
      specs/019-line-pay/tasks.md
  66. 5 0
      specs/024-flash-delivery/plan.md
  67. 10 0
      specs/024-flash-delivery/spec.md
  68. 8 0
      specs/024-flash-delivery/tasks.md
  69. 1 0
      specs/031-pay-method-config/plan.md
  70. 3 0
      specs/031-pay-method-config/spec.md
  71. 6 0
      specs/031-pay-method-config/tasks.md
  72. 58 0
      updatesql/sql.md

+ 1 - 0
AGENTS.md

@@ -10,6 +10,7 @@
 - 匹配现有代码风格。只移除本次修改造成的无用 import、变量或方法。
 - 修复缺陷时优先用可复现检查或测试证明问题,再验证修复结果;多步骤任务给出简短、可验证的执行计划。
 - 当用户说“只改 X”时,只改 X。简单的格式、显示或单文件修改不要创建任务计划,也不要启动 Agent。
+- Git 提交信息使用中文。这是用户明确要求长期保留的偏好,后续 commit/push 均遵守。
 
 ## OMG 支付执行纪律与本次复盘
 

+ 107 - 0
docs/flash-delivery-line-pay-app-integration.md

@@ -0,0 +1,107 @@
+# 闪送骑手 LINE Pay Online 接入(019 增量)
+
+2026-10-10。仅提供接口契约,App 代码由前端人员实现。普通外卖仍支付给商家,本功能不改变外卖配送费结算。
+
+## 前置条件
+
+- 开发者手工执行 `updatesql/sql.md` 中「2026-10-10 019 骑手 LINE Pay 与闪送 Online」迁移和菜单,给平台角色分配权限。
+- 骑手已由 LINE Pay 开通 Online,平台「骑手支付配置」录入其独立凭证并验证启用。骑手必须为正常 `userType=2` 账号;接单仍受原骑手审核、容量、车型等规则约束。
+- `LINE_PAY_ENVIRONMENT` 与 `LINE_PAY_BASE_URL` 必须匹配:sandbox 对应 `https://sandbox-api-pay.line.me`,production 对应 `https://api-pay.line.me`;不能混用。骑手凭证探测、付款、历史核实及退款调用前都会校验,错配时不调用网关。凭证环境来自服务端,不由页面或 App 选择。
+- 新增公网 HTTPS 配置 `LINE_PAY_FLASH_CONFIRM_URL=https://<API域名>/pay/line/flash/confirm` 和 `LINE_PAY_FLASH_CANCEL_URL=https://<API域名>/pay/line/flash/cancel`。默认留空,未配置时不允许发起闪送支付;外卖回跳配置不变。反向代理需放行这两个匿名 GET 入口。
+- 每次付款固定骑手凭证版本,轮换/禁用只阻止新请求,不把历史交易转到新骑手或新密钥。服务器恢复任务、Redis 和数据库须正常工作。
+
+## 主流程
+
+1. 用户沿用现有闪送创建接口,`payType="3"`。此时仅创建配送订单,不发起扣款。
+2. 有启用 LINE 凭证且接受此支付方式的骑手看到订单并接单;服务器接单时重新校验,不能只依赖列表。
+3. 寄件用户读取订单,状态为 `ACCEPTED` 后显示付款入口,调用下面的 create。
+4. create 返回 `WAITING_AUTH` 和 `paymentUrl` 时打开链接进行 LINE 认证。**认证/浏览器返回不等于已付款**。
+5. App 回链、重新打开订单或完成认证后调用 status;服务端通过 LINE Check/Confirm/Retrieve 核实。只有 `status=PAID` 且没有退款阻断才可取件。
+6. 骑手单站及逐站取件均由服务端校验可信资金流水。旧线上单只有 `paymentStatus=1` 而没有可信流水,也不能取件。
+7. 取件前用户/平台合法取消:配送订单立即取消,已付资金生成全额退款意图,恢复任务处理退款。任一取货站已取件后禁止取消,客服另行处理异常。
+
+## App 接口
+
+沿用用户/骑手 JWT **`token` 请求头**,不是平台 `Authorization`。返回仍为项目 `AjaxResult`,HTTP 200 不代表业务成功,须检查业务 `code`。
+
+### 发起付款
+
+`POST /system/flashDelivery/orders/{id}/linePay/create`
+
+显式 JSON 请求体:
+
+```json
+{}
+```
+
+`id` 为闪送订单数字 ID,仅寄件付款人可发起。不要提交 riderId、channelId、channelSecret、金额或币种;服务端从订单及凭证读取。未接单、取消/已取件、不是 LINE 单或收款凭证未就绪时拒绝。
+
+示例(虚构订单及地址):
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "orderId": 101,
+    "paymentId": 202,
+    "status": "WAITING_AUTH",
+    "refundStatus": null,
+    "paymentUrl": "https://<LINE付款域名>/<付款路径>"
+  }
+}
+```
+
+重复 create 返回现有活跃尝试,不重复扣款。`REQUEST_UNKNOWN`、`CONFIRM_UNKNOWN`、`MANUAL_REVIEW` **不可当成支付失败后重新支付**。已发送的未知尝试保留资金阻断;CREATED 尚未发送的意图因凭证禁用/轮换而取消,不代表已发生扣款。
+
+### 查询支付/退款
+
+`GET /system/flashDelivery/orders/{id}/linePay/status`
+
+寄件付款人及本单接单骑手可查询;他人拒绝。字段与 create 一致,骑手不返回 paymentUrl。无尝试时 `status=NOT_STARTED`、paymentId/paymentUrl/refundStatus 为空。
+
+| status | 前端处理 |
+| --- | --- |
+| NOT_STARTED / CREATED | 尚未完成付款请求,仅在服务器允许时发起/继续当前尝试 |
+| REQUESTING / CONFIRMING | 请求处理中,查询当前尝试,不重复付款 |
+| WAITING_AUTH | 待用户认证/付款;寄件人可打开当前 paymentUrl |
+| REQUEST_UNKNOWN / CONFIRM_UNKNOWN | 结果待核实,查询并等待,禁止第二次扣款 |
+| PAID | 服务端已核实到账;如 refundStatus 非空则不能据此继续取件 |
+| FAILED / CANCELLED / EXPIRED | 此尝试结束,不等于配送订单已取消;只有有效 ACCEPTED 订单才能按服务端规则重试 |
+| MANUAL_REVIEW | 资金需要人工核对,不能伪造成功或自动重新支付 |
+
+refundStatus:空=无退款意图;CREATED=待退款,PROCESSING=退款请求中,UNKNOWN=退款结果待核实,REFUNDED=已证实全额退款,FAILED/MANUAL_REVIEW=失败/人工核对。支付 PAID 历史事实不会被退款擦除。
+
+订单原 `paymentStatus`:LINE 单 0=未核实到账、1=已到账、2=已证实全额退款;现金/转账沿用 0/1。不要只按此汇总标记放行取件;最终由取件接口校验。
+
+## 回链
+
+服务器专属回跳 `/pay/line/flash/confirm`、`/cancel` 根据已保存 LINE 订单号及字符串交易号定位尝试,仅排队核实,不根据浏览器参数写支付成功。
+
+App 深链:`com.twanmsdyh.app://payment/result?orderSource=FLASH&orderId=<闪送数字ID>`。前端依据 `orderSource=FLASH` 调用本文件的 status,**不要调用外卖查询,不要信任深链判定成功**。不含有效绑定的浏览器回跳显示通用提示;定时任务仍会核实资金。浏览器取消认证不取消配送订单。
+
+返回页有 CSP/no-store;手机浏览器自动唤起可能受限,提供手工打开入口。iOS/Android Scheme 注册和真实 LINE WebView 联调由 App 人员验证。
+
+## 取消与资金恢复
+
+- 用户取消沿用原接口;平台取消仍须原因、属于本单的问题站、处理结果。取件后即使提供物品去向/交接照片也不能取消;历史交接记录仍可读。
+- 平台列表行/详情返回 `canAdminCancel`;按钮只在严格为 true 时展示,缺字段安全隐藏。平台打开弹窗时再读取详情,接口最终在订单锁内复核站点。
+- 取消与已付款退款意图同一事务提交,退款网络调用在事务外;真实退款成功前不展示“已退款”。
+- 取消先成功后不再新发 Confirm;已经在途的扣款若迟到证实,只创建退款补偿,不恢复配送状态。
+- Confirm/Refund 超时或进程失联后只读查询证据,不盲目重发。退款证据已存在则直接登记事实;部分/异常退款进入人工核对,防止超额退款或已退订单继续取件。
+- 复用 `line-pay.reconcile` 批量、间隔、租约及截止配置,独立任务锁 `lock:flash-line-pay:reconcile`。租约至少 120 秒覆盖现有 HTTP 超时;到期不等于网关未扣款,人工核对保留活跃阻断。
+- 每次探测(含未变状态或查询失败)推进下一次核实时间,按最早到期、ID 排队,避免前一批等待认证/未决退款挤占后续订单。仍有有效租约的请求不被探测更新干扰。
+- 取件在订单锁之后对支付、退款使用数据库当前读,不能读到退款前旧快照;已经证实 REFUNDED 的事实不能被旧探测的截止时间降级。
+- 骑手不能通过 `confirmPayment` 手工把 LINE 单标记已收款;现金及线下转账原送达后确认收款不变。
+
+## 平台配置接口
+
+平台使用既有后台登录 Authorization。GET `/system/riderLinePay/list`(分页/userName)、GET `/{riderId}`、PUT `/saveCredentials`(riderId/channelId/channelSecret)、PUT `/toggleEnable`(riderId/enabled)。
+
+分别需要 `infouser:riderLinePay:list/query/saveCredentials/toggleEnable` 权限。列表不查询 Secret;授权详情可用于回显当前凭证;保存时只读随机不存在订单探测,验证失败不替换原版本。Secret 不进操作审计、App 响应或浏览器持久缓存;凭证保存请求专门绕过通用重复提交 sessionStorage 缓存,页面 saving 仍防重复点击。本文不提供真实密钥。
+
+## 验证边界
+
+本地测试覆盖签名/请求契约、可信事实、重复/未知保护、退款补偿、权限归属和取件/取消门禁。SQL 本次未执行;真实 MySQL 多连接竞态、LINE Sandbox/正式骑手收款和退款、部署、App/浏览器联调须另行验证,不能用 mock 测试代替真实资金验收。
+
+2026-10-10 最终本地回归:164 个后端测试类、1140 项测试,0 失败/错误/跳过;JDK 21 模块 package 成功;平台三组行为测试 16/16,全部现有测试 25/25,build:stage 成功。独立审查的 5 项重要发现已用失败→通过回归修复。当前读仅验证 Mapper 实际生成的锁定 SQL 和旧快照行为模型,不代表已运行真实 MySQL 多连接测试。未提交/推送。

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java

@@ -22,6 +22,10 @@ public class FlashDeliveryAdminOrderDetailView {
     private Integer pickedUpStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
     private Boolean canUserCancel;
+    /** 服务端按最新站点事实计算;缺站点或任一站已取货时为 false。 */
+    private Boolean canAdminCancel;
+    /** LINE Pay 本地资金事实,不含支付链接及密钥。 */
+    private FlashLinePayPaymentView linePayPayment;
     /** 按用户原始顺序返回站点、独立物品及本站凭证。 */
     private List<FlashDeliveryStopView> stops = List.of();
     /** 当前前往目标,全部取货后指向唯一收货站。 */

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashLinePayCreateRequest.java

@@ -0,0 +1,3 @@
+package com.ruoyi.app.flashdelivery.dto;
+/** 空业务请求。金额、收款骑手和凭证不能由 App 提交。 */
+public class FlashLinePayCreateRequest { }

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashLinePayPaymentView.java

@@ -0,0 +1,10 @@
+package com.ruoyi.app.flashdelivery.dto;
+import lombok.Data;
+/** 支付与履约分别查询;支付链接仅向寄件付款用户提供。 */
+@Data public class FlashLinePayPaymentView {
+ private Long orderId;
+ private Long paymentId;
+ private String status;
+ private String refundStatus;
+ private String paymentUrl;
+}

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayAsyncService.java

@@ -0,0 +1,9 @@
+package com.ruoyi.app.flashdelivery.payment;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+/** 回跳先返回安全页面;拒绝入队仍有持久恢复任务,不依赖内存队列保证资金正确。 */
+@Service public class FlashLinePayAsyncService {
+ private final FlashLinePayService service;
+ public FlashLinePayAsyncService(FlashLinePayService service){this.service=service;}
+ @Async("threadPoolTaskExecutor") public void reconcile(Long id,String source){service.reconcile(id,source);}
+}

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayController.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.app.flashdelivery.dto.FlashLinePayCreateRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.*;
+/** App 只传闪送订单 ID;付款人从 token 解析,收款主体/金额从订单取得。 */
+@RestController @Anonymous @RequestMapping("/system/flashDelivery/orders/{id}/linePay")
+public class FlashLinePayController {
+ private final FlashLinePayService service;
+ public FlashLinePayController(FlashLinePayService service){this.service=service;}
+ @Auth @PostMapping("/create")
+ public AjaxResult create(@RequestHeader String token,@PathVariable Long id,@RequestBody FlashLinePayCreateRequest request){return AjaxResult.success(service.create(userId(token),id));}
+ @Auth @GetMapping("/status")
+ public AjaxResult status(@RequestHeader String token,@PathVariable Long id){return AjaxResult.success(service.query(userId(token),id));}
+ private Long userId(String token){return Long.valueOf(new JwtUtil().getusid(token));}
+}

+ 41 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayEvidence.java

@@ -0,0 +1,41 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import java.math.BigDecimal;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.system.domain.flash.FlashDeliveryLinePayment;
+/** 只把匹配原始交易绑定的网关证据交给资金事实服务;无效 JSON/小数金额均不作到账。 */
+final class FlashLinePayEvidence {
+ static JSONObject captured(LinePayResponse response,FlashDeliveryLinePayment payment,boolean confirmed){
+     if(response==null || !response.isSuccess() || response.rawBody()==null)return null;
+     try {
+         Object info=JSON.parseObject(response.rawBody()).get("info");JSONObject tx;
+         if(confirmed){if(!(info instanceof JSONObject))return null;tx=(JSONObject)info;}
+         else {if(!(info instanceof JSONArray) || ((JSONArray)info).size()!=1)return null;tx=((JSONArray)info).getJSONObject(0);if(tx==null || !"PAYMENT".equals(tx.getString("transactionType")))return null;}
+         String id=tx.getString("transactionId");
+         if(id==null || id.isBlank() || payment.getTransactionId()!=null && !payment.getTransactionId().equals(id)
+                 || !payment.getLineOrderId().equals(tx.getString("orderId"))
+                 || tx.getString("currency")!=null && !payment.getCurrency().equals(tx.getString("currency")) || amount(tx)<=0)return null;
+         return tx;
+     }catch(RuntimeException malformed){return null;}
+ }
+ static int amount(JSONObject transaction){
+     try {
+         JSONArray list=transaction.getJSONArray("payInfo");if(list==null || list.isEmpty())return -1;long total=0;
+         for(int i=0;i<list.size();i++){BigDecimal raw=list.getJSONObject(i).getBigDecimal("amount");if(raw==null)return -1;long value=raw.longValueExact();if(value<=0)return -1;total=Math.addExact(total,value);}
+         return total<=Integer.MAX_VALUE?(int)total:-1;
+     }catch(RuntimeException invalid){return -1;}
+ }
+ static String refundedId(JSONObject transaction,int expectedAmount){
+     try {
+         JSONArray list=transaction.getJSONArray("refundList");if(list==null || list.isEmpty())return null;long total=0;String tx=null;
+         for(int i=0;i<list.size();i++){JSONObject item=list.getJSONObject(i);long value=item.getBigDecimal("refundAmount").longValueExact();tx=item.getString("refundTransactionId");if(value==0 || value==Long.MIN_VALUE || tx==null || tx.isBlank())return null;total=Math.addExact(total,Math.abs(value));}
+         return total==expectedAmount?tx:null;
+     }catch(RuntimeException invalid){return null;}
+ }
+ static boolean hasRefund(JSONObject transaction){
+     Object raw=transaction.get("refundList");
+     return raw!=null && (!(raw instanceof JSONArray) || !((JSONArray)raw).isEmpty());
+ }
+}

+ 175 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayFactService.java

@@ -0,0 +1,175 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.*;
+/** 短事务资金事实;锁序固定为订单→支付→退款,任何 HTTP 都在调用方事务外。 */
+@Service
+public class FlashLinePayFactService {
+ private final FlashDeliveryOrderMapper orders;
+ private final FlashDeliveryLinePaymentMapper payments;
+ private final FlashDeliveryLineRefundMapper refunds;
+ private final IInfoRiderLinePayService credentials;
+ @org.springframework.beans.factory.annotation.Value("${line-pay.reconcile.row-lease-seconds:120}") private long rowLeaseSeconds=120;
+ @org.springframework.beans.factory.annotation.Value("${line-pay.reconcile.auth-deadline-minutes:30}") private long authDeadlineMinutes=30;
+ @org.springframework.beans.factory.annotation.Value("${line-pay.reconcile.unknown-deadline-hours:24}") private long unknownDeadlineHours=24;
+ public FlashLinePayFactService(FlashDeliveryOrderMapper o,FlashDeliveryLinePaymentMapper p,FlashDeliveryLineRefundMapper r,IInfoRiderLinePayService c){orders=o;payments=p;refunds=r;credentials=c;}
+
+ @Transactional(rollbackFor=Exception.class)
+ public FlashDeliveryLinePayment createIntent(Long userId,Long orderId){
+     FlashDeliveryOrder order=orders.selectByIdForUpdate(orderId);
+     if(order==null || !Objects.equals(userId,order.getUserId())) throw fail("not.found");
+     if(!"ACCEPTED".equals(order.getStatus()) || order.getRiderId()==null || !"3".equals(order.getPayType())
+             || order.getPickedUpAt()!=null || order.getPickedUpStopCount()!=null && order.getPickedUpStopCount()>0) throw fail("accept.required");
+     if(order.getAmount()==null || order.getAmount()<=0 || order.getAmount()>Integer.MAX_VALUE || !"TWD".equals(order.getCurrency())) throw fail("amount.invalid");
+     FlashDeliveryLinePayment previous=payments.selectLatestForUpdate(orderId);
+     // 未知资金结果不释放活跃键;禁止利用网络超时发起第二次扣款。
+     if(previous!=null && (previous.getActiveOrderId()!=null || "PAID".equals(previous.getStatus()))) return previous;
+     InfoRiderLinePay credential=credentials.getEnabledCurrent(order.getRiderId());
+     if(credential==null) throw fail("config.invalid");
+     Date now=new Date(); FlashDeliveryLinePayment row=new FlashDeliveryLinePayment();
+     row.setOrderId(orderId);row.setRiderId(order.getRiderId());row.setCredentialId(credential.getId());
+     row.setLineOrderId("FLP"+UUID.randomUUID().toString().replace("-",""));row.setAmount(Math.toIntExact(order.getAmount()));row.setCurrency("TWD");
+     row.setStatus("CREATED");row.setActiveOrderId(orderId);row.setVersion(0L);row.setCreateTime(now);row.setUpdateTime(now);
+     row.setNextReconcileAt(now);row.setReconcileDeadline(new Date(now.getTime()+Math.max(1,unknownDeadlineHours)*60*60*1000L));
+     if(payments.insert(row)!=1) throw fail("unknown");return row;
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public FlashDeliveryLinePayment claim(Long id,String expected,String next){
+     FlashDeliveryLinePayment preliminary=payments.selectById(id);if(preliminary==null)return null;
+     FlashDeliveryOrder order=orders.selectByIdForUpdate(preliminary.getOrderId());
+     FlashDeliveryLinePayment row=payments.selectForUpdate(id);
+     if(row==null || !expected.equals(row.getStatus()) || order==null || !"ACCEPTED".equals(order.getStatus())
+             || !Objects.equals(row.getRiderId(),order.getRiderId()) || !Objects.equals(row.getAmount().longValue(),order.getAmount()) || !"3".equals(order.getPayType()))return null;
+     if("CREATED".equals(expected)){
+         // 领取首次请求时再次检查凭证,意图创建后的禁用/轮换不能继续新收款。
+         InfoRiderLinePay current=credentials.getEnabledCurrent(order.getRiderId());
+         if(current==null || !Objects.equals(current.getId(),row.getCredentialId()))return null;
+     }
+     row.setStatus(next);row.setLeaseUntil(new Date(System.currentTimeMillis()+Math.max(120,rowLeaseSeconds)*1000L));save(row);return row;
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public void requestResult(Long id,Long version,String transactionId,String url){
+     FlashDeliveryLinePayment row=lockAttempt(id);
+     if(row==null || !Objects.equals(row.getVersion(),version) || !"REQUESTING".equals(row.getStatus()))return;
+     row.setTransactionId(transactionId);row.setPaymentUrl(url);row.setStatus("WAITING_AUTH");row.setLeaseUntil(null);
+     row.setReconcileDeadline(new Date(System.currentTimeMillis()+Math.max(1,authDeadlineMinutes)*60*1000L));save(row);
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public void setStatus(Long id,Long expectedVersion,String expectedStatus,String nextStatus,boolean terminal){
+     FlashDeliveryLinePayment row=lockAttempt(id);
+     if(row==null || !Objects.equals(row.getVersion(),expectedVersion) || !Objects.equals(row.getStatus(),expectedStatus))return;
+     row.setStatus(nextStatus);row.setLeaseUntil(null);if(terminal)row.setActiveOrderId(null);save(row);
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public boolean applyPaidFact(Long id,String tx,long amount,Date at){
+     return applyGatewayFact(id,tx,amount,at,null,false);
+ }
+
+ /** 已退款的 Retrieve 与到账一起落库,避免提交 PAID 后再拦截取件的竞态窗口。 */
+ @Transactional(rollbackFor=Exception.class)
+ public boolean applyGatewayFact(Long id,String tx,long amount,Date at,String refundTx,boolean hasRefund){
+     FlashDeliveryLinePayment preliminary=payments.selectById(id);if(preliminary==null)return false;
+     FlashDeliveryOrder order=orders.selectByIdForUpdate(preliminary.getOrderId());
+     FlashDeliveryLinePayment row=payments.selectForUpdate(id);if(row==null || order==null)return false;
+     if(tx==null || amount<=0 || amount>Integer.MAX_VALUE || row.getTransactionId()!=null && !row.getTransactionId().equals(tx))throw fail("unknown");
+     boolean valid=amount==row.getAmount() && Objects.equals(row.getRiderId(),order.getRiderId()) && Objects.equals(amount,order.getAmount()) && "3".equals(order.getPayType());
+     row.setTransactionId(tx);row.setCapturedAmount(Math.toIntExact(amount));if(row.getPaidAt()==null)row.setPaidAt(at);
+     row.setStatus(valid && (!hasRefund || refundTx!=null)?"PAID":"MANUAL_REVIEW");row.setLeaseUntil(null);save(row);
+     FlashDeliveryLineRefund existing=refunds.selectByPaymentIdForUpdate(id);
+     if(hasRefund)existing=refundIntent(row,at,refundTx==null?"MANUAL_REVIEW":"REFUNDED",refundTx);
+     int paymentStatus=existing!=null && "REFUNDED".equals(existing.getStatus())?2:valid && !hasRefund?1:0;
+     if(orders.update(null,new UpdateWrapper<FlashDeliveryOrder>().eq("id",order.getId()).eq("pay_type","3").set("payment_status",paymentStatus).set("update_time",at))!=1)throw fail("unknown");
+     // 迟到付款只补偿退款,不恢复取消订单;意图和资金事实同事务落库。
+     if("CANCELLED".equals(order.getStatus()) && existing==null)refundIntent(row,at,"CREATED",null);
+     return true;
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public void ensureCancellationRefundIntent(Long orderId){
+     FlashDeliveryOrder order=orders.selectByIdForUpdate(orderId);if(order==null || !"3".equals(order.getPayType()))return;
+     FlashDeliveryLinePayment row=payments.selectLatestForUpdate(orderId);
+     if(row==null || row.getCapturedAmount()==null || row.getCapturedAmount()<=0)return;
+     if(refunds.selectByPaymentIdForUpdate(row.getId())!=null)return;
+     refundIntent(row,new Date(),"CREATED",null);
+ }
+ private FlashDeliveryLineRefund refundIntent(FlashDeliveryLinePayment payment,Date now,String status,String refundTx){
+     FlashDeliveryLineRefund existing=refunds.selectByPaymentIdForUpdate(payment.getId());
+     if(existing!=null){
+         if(refundTx!=null && !"REFUNDED".equals(existing.getStatus())){
+             existing=refunds.selectForUpdate(existing.getId());existing.setStatus("REFUNDED");existing.setRefundTransactionId(refundTx);existing.setRefundedAt(now);existing.setLeaseUntil(null);saveRefund(existing);
+         }return existing;
+     }
+     FlashDeliveryLineRefund refund=new FlashDeliveryLineRefund();refund.setPaymentId(payment.getId());refund.setAmount(payment.getCapturedAmount());
+     refund.setStatus(status);refund.setRefundTransactionId(refundTx);if(refundTx!=null)refund.setRefundedAt(now);
+     refund.setVersion(0L);refund.setCreateTime(now);refund.setUpdateTime(now);refund.setNextReconcileAt(now);
+     refund.setReconcileDeadline(new Date(now.getTime()+Math.max(1,unknownDeadlineHours)*60*60*1000L));if(refunds.insert(refund)!=1)throw fail("unknown");return refund;
+ }
+
+ /** 在取件已有订单行锁内调用;骑手手工收款标志不是 LINE Pay 到账证据。 */
+ public void assertPickupPaid(FlashDeliveryOrder order){
+     if(!"3".equals(order.getPayType()))return;
+     // requireRider 可能先建立 RR 快照;等订单锁后用当前读,不能放行退款前的旧 PAID。
+     FlashDeliveryLinePayment row=payments.selectLatestForUpdate(order.getId());
+     if(row==null || !"PAID".equals(row.getStatus()) || row.getPaidAt()==null || row.getTransactionId()==null
+             || row.getCapturedAmount()==null || !Objects.equals(row.getCapturedAmount().longValue(),order.getAmount())
+             || !Objects.equals(row.getRiderId(),order.getRiderId()) || refunds.selectByPaymentIdForUpdate(row.getId())!=null)throw fail("pickup.unpaid");
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public FlashDeliveryLineRefund claimRefund(Long id){
+     FlashDeliveryLineRefund initial=refunds.selectById(id);if(initial==null)return null;
+     FlashDeliveryLinePayment payment=payments.selectById(initial.getPaymentId());if(payment==null)return null;
+     FlashDeliveryOrder order=orders.selectByIdForUpdate(payment.getOrderId());payments.selectForUpdate(payment.getId());
+     FlashDeliveryLineRefund row=refunds.selectForUpdate(id);
+     if(row==null || order==null || !"CANCELLED".equals(order.getStatus()) || !"CREATED".equals(row.getStatus()))return null;
+     row.setStatus("PROCESSING");row.setLeaseUntil(new Date(System.currentTimeMillis()+Math.max(120,rowLeaseSeconds)*1000L));saveRefund(row);return row;
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public void refundStatus(Long id,Long version,String expected,String next){
+     FlashDeliveryLineRefund row=lockRefund(id);
+     // 查询途中可能已有其他事务写入全额退款,截止时间不得降级可信终态。
+     if(row==null || Set.of("REFUNDED","FAILED","MANUAL_REVIEW").contains(row.getStatus()) || !Objects.equals(version,row.getVersion()) || !expected.equals(row.getStatus()))return;
+     row.setStatus(next);row.setLeaseUntil(null);saveRefund(row);
+ }
+
+ @Transactional(rollbackFor=Exception.class)
+ public void applyRefundedFact(Long refundId,String refundTx,Date at){
+     FlashDeliveryLineRefund row=lockRefund(refundId);if(row==null || "REFUNDED".equals(row.getStatus()))return;
+     if(refundTx==null || refundTx.isBlank())throw fail("unknown");
+     FlashDeliveryLinePayment payment=payments.selectById(row.getPaymentId());
+     row.setStatus("REFUNDED");row.setRefundTransactionId(refundTx);row.setRefundedAt(at);row.setLeaseUntil(null);saveRefund(row);
+     if(orders.update(null,new UpdateWrapper<FlashDeliveryOrder>().eq("id",payment.getOrderId()).eq("pay_type","3").set("payment_status",2).set("update_time",at))!=1)throw fail("unknown");
+ }
+ private FlashDeliveryLinePayment lockAttempt(Long id){FlashDeliveryLinePayment row=payments.selectById(id);if(row==null)return null;orders.selectByIdForUpdate(row.getOrderId());return payments.selectForUpdate(id);}
+ /** 每次探测都推进下次时间,配合最早到期排序避免前一批未决记录长期占住队列。 */
+ @Transactional(rollbackFor=Exception.class)
+ public void deferPaymentProbe(Long id){
+     FlashDeliveryLinePayment row=lockAttempt(id);
+     if(row==null || !Set.of("CREATED","REQUESTING","WAITING_AUTH","CONFIRMING","REQUEST_UNKNOWN","CONFIRM_UNKNOWN").contains(row.getStatus())
+             || row.getLeaseUntil()!=null && row.getLeaseUntil().after(new Date()))return;
+     save(row);
+ }
+ @Transactional(rollbackFor=Exception.class)
+ public void deferRefundProbe(Long id){
+     FlashDeliveryLineRefund row=lockRefund(id);
+     if(row==null || !Set.of("CREATED","PROCESSING","UNKNOWN").contains(row.getStatus())
+             || row.getLeaseUntil()!=null && row.getLeaseUntil().after(new Date()))return;
+     saveRefund(row);
+ }
+ private FlashDeliveryLineRefund lockRefund(Long id){FlashDeliveryLineRefund row=refunds.selectById(id);if(row==null)return null;FlashDeliveryLinePayment p=payments.selectById(row.getPaymentId());if(p==null)return null;orders.selectByIdForUpdate(p.getOrderId());payments.selectForUpdate(p.getId());return refunds.selectForUpdate(id);}
+ private void save(FlashDeliveryLinePayment row){long expected=row.getVersion();row.setUpdateTime(new Date());row.setNextReconcileAt(new Date(System.currentTimeMillis()+30_000L));if(payments.updateVersioned(row,expected)!=1)throw fail("unknown");row.setVersion(expected+1);}
+ private void saveRefund(FlashDeliveryLineRefund row){long expected=row.getVersion();row.setUpdateTime(new Date());row.setNextReconcileAt(new Date(System.currentTimeMillis()+30_000L));if(refunds.updateVersioned(row,expected)!=1)throw fail("unknown");row.setVersion(expected+1);}
+ private static ServiceException fail(String key){return new ServiceException(MessageUtils.message("flash.line."+key));}
+}

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRefundService.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.flash.*;
+import com.alibaba.fastjson2.JSONObject;
+import org.springframework.stereotype.Service;
+import java.util.*;
+/** 全额退款先持久化领取意图,再调用网关;未知结果只能查询,不能重新退款。 */
+@Service
+public class FlashLinePayRefundService {
+ private final FlashLinePayFactService facts;
+ private final FlashDeliveryLinePaymentMapper payments;
+ private final FlashDeliveryLineRefundMapper refunds;
+ private final IInfoRiderLinePayService credentials;
+ private final LinePayClient client;
+ private final LinePayProperties properties;
+ public FlashLinePayRefundService(FlashLinePayFactService f,FlashDeliveryLinePaymentMapper p,FlashDeliveryLineRefundMapper r,IInfoRiderLinePayService c,LinePayClient client,LinePayProperties props){facts=f;payments=p;refunds=r;credentials=c;this.client=client;properties=props;}
+ public void reconcile(Long id,String source){
+     try { reconcileAttempt(id,source); }
+     finally { facts.deferRefundProbe(id); }
+ }
+ private void reconcileAttempt(Long id,String source){
+     FlashDeliveryLineRefund row=refunds.selectById(id);
+     if(row==null || Set.of("REFUNDED","FAILED","MANUAL_REVIEW").contains(row.getStatus()))return;
+     Date now=new Date();
+     if("PROCESSING".equals(row.getStatus())){
+         if(row.getLeaseUntil()!=null && row.getLeaseUntil().after(now))return;
+         // 进程可能已发送退款后崩溃,租约到期不是重发授权。
+         facts.refundStatus(id,row.getVersion(),"PROCESSING","UNKNOWN");row=refunds.selectById(id);if(row==null)return;
+     }
+     try {
+         FlashDeliveryLinePayment payment=payments.selectById(row.getPaymentId());
+         InfoRiderLinePay original=payment==null?null:credentials.getById(payment.getCredentialId());
+         if(properties.isRiderEnvironmentMatched() && original!=null && Objects.equals(original.getEnvironment(),properties.getEnvironment().toUpperCase(Locale.ROOT))){
+             LinePayCredential credential=new LinePayCredential(original.getId(),original.getChannelId(),original.getChannelSecret());
+             JSONObject tx=FlashLinePayEvidence.captured(client.retrieveByOrderId(credential,payment.getLineOrderId()),payment,false);
+             if(tx!=null && Objects.equals(FlashLinePayEvidence.amount(tx),row.getAmount()) && Objects.equals(row.getAmount(),payment.getCapturedAmount())){
+                 String refunded=FlashLinePayEvidence.refundedId(tx,row.getAmount());
+                 if(refunded!=null){facts.applyRefundedFact(id,refunded,now);return;}
+                 if(FlashLinePayEvidence.hasRefund(tx)){
+                     // 部分退款或无法解释的退款证据不能再盲目全额退款。
+                     facts.refundStatus(id,row.getVersion(),row.getStatus(),"MANUAL_REVIEW");return;
+                 }
+                 if("CREATED".equals(row.getStatus())){
+                     FlashDeliveryLineRefund claimed=facts.claimRefund(id);if(claimed==null)return;
+                     request(claimed,payment,credential);return;
+                 }
+             }
+         }
+     }catch(Exception unknown){/* 原凭证查询失败不改变资金事实,交给持久恢复任务继续核实。 */}
+     row=refunds.selectById(id);
+     if(row!=null && Set.of("CREATED","PROCESSING","UNKNOWN").contains(row.getStatus()) && row.getReconcileDeadline()!=null && !row.getReconcileDeadline().after(now))facts.refundStatus(id,row.getVersion(),row.getStatus(),"MANUAL_REVIEW");
+ }
+ private void request(FlashDeliveryLineRefund row,FlashDeliveryLinePayment payment,LinePayCredential credential){
+     try {
+         LinePayResponse response=client.refundFull(credential,payment.getTransactionId());
+         String tx=response==null || response.info()==null?null:response.info().getString("refundTransactionId");
+         if(response!=null && response.isSuccess() && tx!=null && !tx.isBlank()){
+             facts.applyRefundedFact(row.getId(),tx,new Date());return;
+         }
+     }catch(Exception unknown){/* 已受理但响应丢失时只可 Retrieve,防止重复退款。 */}
+     facts.refundStatus(row.getId(),row.getVersion(),"PROCESSING","UNKNOWN");
+ }
+}

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayReturnController.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.system.domain.flash.FlashDeliveryLinePayment;
+import org.springframework.http.*;
+import org.springframework.web.bind.annotation.*;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+/** 匿名浏览器回跳必须匹配已保存的订单号和交易号,不能创建或修改收款主体。 */
+@RestController @RequestMapping("/pay/line/flash")
+public class FlashLinePayReturnController {
+ private final FlashLinePayService service;private final FlashLinePayAsyncService async;private final FlashLinePayReturnPageRenderer renderer;
+ public FlashLinePayReturnController(FlashLinePayService service,FlashLinePayAsyncService async,FlashLinePayReturnPageRenderer renderer){this.service=service;this.async=async;this.renderer=renderer;}
+ @Anonymous @GetMapping("/confirm") public ResponseEntity<String> confirm(@RequestParam String orderId,@RequestParam String transactionId){return page(orderId,transactionId);}
+ @Anonymous @GetMapping("/cancel") public ResponseEntity<String> cancel(@RequestParam(required=false) String orderId,@RequestParam(required=false) String transactionId){return page(orderId,transactionId);}
+ private ResponseEntity<String> page(String orderId,String transactionId){
+     FlashDeliveryLinePayment payment=service.findByLineOrderId(orderId,transactionId);
+     if(payment!=null){try{async.reconcile(payment.getId(),"BROWSER");}catch(RuntimeException unavailable){/* 持久定时任务继续核实。 */}}
+     String nonce=UUID.randomUUID().toString().replace("-","");HttpHeaders headers=new HttpHeaders();
+     headers.setContentType(new MediaType("text","html",StandardCharsets.UTF_8));headers.set("Cache-Control","no-store");headers.set("Referrer-Policy","no-referrer");headers.set("X-Content-Type-Options","nosniff");
+     headers.set("Content-Security-Policy","default-src 'none'; script-src 'nonce-"+nonce+"'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
+     return ResponseEntity.ok().headers(headers).body(renderer.render(payment==null?null:payment.getOrderId(),nonce));
+ }
+}

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayReturnPageRenderer.java

@@ -0,0 +1,11 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.stereotype.Component;
+/** 只包含服务端订单 ID 的回链;App 必须查询最终资金状态,不能信任回链。 */
+@Component public class FlashLinePayReturnPageRenderer {
+ public String render(Long orderId,String nonce){
+     String link="com.twanmsdyh.app://payment/result?orderSource=FLASH&amp;orderId="+(orderId==null?"":orderId);
+     String text=MessageUtils.message("flash.line.unknown").replace("&","&amp;").replace("<","&lt;").replace(">","&gt;");
+     return "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>LINE Pay</title></head><body><p>"+text+"</p><a id=\"open-app\" href=\""+link+"\">LINE Pay</a><script nonce=\""+nonce+"\">setTimeout(function(){location.href=document.getElementById('open-app').href},80)</script></body></html>";
+ }
+}

+ 128 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayService.java

@@ -0,0 +1,128 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.app.flashdelivery.dto.FlashLinePayPaymentView;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.alibaba.fastjson2.JSONObject;
+import org.springframework.stereotype.Service;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+/** 闪送 Online 编排;持久意图先提交,HTTP 后再以短事务落事实,不持有订单锁等待网络。 */
+@Service
+public class FlashLinePayService {
+ private final FlashLinePayFactService facts;
+ private final FlashDeliveryOrderMapper orders;
+ private final FlashDeliveryLinePaymentMapper payments;
+ private final FlashDeliveryLineRefundMapper refunds;
+ private final IInfoRiderLinePayService credentials;
+ private final LinePayClient client;
+ private final LinePayProperties properties;
+ public FlashLinePayService(FlashLinePayFactService f,FlashDeliveryOrderMapper o,FlashDeliveryLinePaymentMapper p,FlashDeliveryLineRefundMapper r,IInfoRiderLinePayService c,LinePayClient client,LinePayProperties properties){facts=f;orders=o;payments=p;refunds=r;credentials=c;this.client=client;this.properties=properties;}
+ public FlashLinePayPaymentView create(Long userId,Long orderId){
+     if(!properties.isRiderEnvironmentMatched())throw fail("config.invalid");
+     validateRedirect(properties.getFlashConfirmUrl());validateRedirect(properties.getFlashCancelUrl());
+     FlashDeliveryLinePayment row=facts.createIntent(userId,orderId);
+     if("CREATED".equals(row.getStatus()))request(row);
+     return view(orderId,payments.selectById(row.getId()),true);
+ }
+ public FlashLinePayPaymentView query(Long callerId,Long orderId){
+     FlashDeliveryOrder order=orders.selectById(orderId);
+     if(order==null || !Objects.equals(callerId,order.getUserId()) && !Objects.equals(callerId,order.getRiderId()))throw fail("not.found");
+     FlashDeliveryLinePayment row=payments.selectLatest(orderId);if(row!=null)reconcile(row.getId(),"QUERY");
+     return view(orderId,payments.selectLatest(orderId),Objects.equals(callerId,order.getUserId()));
+ }
+ private void request(FlashDeliveryLinePayment initial){
+     InfoRiderLinePay current=credentials.getEnabledCurrent(initial.getRiderId());
+     if(current==null || !Objects.equals(current.getId(),initial.getCredentialId())){
+         // CREATED 证明尚未发送资金请求,可以安全终止;历史已发请求绝不走此分支。
+         facts.setStatus(initial.getId(),initial.getVersion(),"CREATED","CANCELLED",true);return;
+     }
+     LinePayCredential credential=credential(initial);validateRedirect(properties.getFlashConfirmUrl());validateRedirect(properties.getFlashCancelUrl());
+     FlashDeliveryLinePayment row=facts.claim(initial.getId(),"CREATED","REQUESTING");if(row==null)return;
+     // 领取意图后只发送一次 Request。响应丢失保留活跃键,恢复任务不能重发。
+     try {
+         LinePayResponse response=client.request(credential,new LinePayRequest(row.getLineOrderId(),row.getOrderId().toString(),"Flash delivery "+row.getOrderId(),row.getAmount(),row.getCurrency()),
+                 redirect(properties.getFlashConfirmUrl(),row.getLineOrderId()),redirect(properties.getFlashCancelUrl(),row.getLineOrderId()));
+         String url=response!=null && response.info()!=null && response.info().getJSONObject("paymentUrl")!=null?response.info().getJSONObject("paymentUrl").getString("web"):null;
+         if(response!=null && response.isSuccess() && response.transactionId()!=null && !response.transactionId().isBlank() && safeHttps(url)){
+             facts.requestResult(row.getId(),row.getVersion(),response.transactionId(),url);return;
+         }
+     }catch(Exception unknown){/* 不能把本地网络错误解释成网关未受理。 */}
+     facts.setStatus(row.getId(),row.getVersion(),"REQUESTING","REQUEST_UNKNOWN",false);
+ }
+ public void reconcile(Long paymentId,String source){
+     try { reconcileAttempt(paymentId,source); }
+     finally { facts.deferPaymentProbe(paymentId); }
+ }
+ private void reconcileAttempt(Long paymentId,String source){
+     FlashDeliveryLinePayment row=payments.selectById(paymentId);if(row==null || Set.of("FAILED","CANCELLED","EXPIRED","MANUAL_REVIEW").contains(row.getStatus()))return;
+     Date now=new Date();
+     if("CREATED".equals(row.getStatus())){
+         FlashDeliveryOrder o=orders.selectById(row.getOrderId());
+         if(o==null || !"ACCEPTED".equals(o.getStatus()))facts.setStatus(row.getId(),row.getVersion(),"CREATED","CANCELLED",true);
+         else request(row);return;
+     }
+     if(Set.of("REQUESTING","CONFIRMING").contains(row.getStatus())){
+         if(row.getLeaseUntil()!=null && row.getLeaseUntil().after(now))return;
+         facts.setStatus(row.getId(),row.getVersion(),row.getStatus(),"CONFIRMING".equals(row.getStatus())?"CONFIRM_UNKNOWN":"REQUEST_UNKNOWN",false);
+         row=payments.selectById(paymentId);if(row==null)return;
+     }
+     try {
+         LinePayCredential credential=credential(row);
+         LinePayResponse retrieve=client.retrieveByOrderId(credential,row.getLineOrderId());
+         JSONObject tx=FlashLinePayEvidence.captured(retrieve,row,false);
+         if(tx!=null){facts.applyGatewayFact(row.getId(),tx.getString("transactionId"),FlashLinePayEvidence.amount(tx),now,
+                 FlashLinePayEvidence.refundedId(tx,FlashLinePayEvidence.amount(tx)),FlashLinePayEvidence.hasRefund(tx));return;}
+         if("PAID".equals(row.getStatus()))return;
+         // Confirm 未知只查询,不重新 Confirm。明确失败/取消的 Check 才允许释放活跃键。
+         if(row.getTransactionId()!=null && retrieve!=null && "1150".equals(retrieve.returnCode())){
+             LinePayResponse check=client.check(credential,row.getTransactionId());
+             if(check!=null && check.httpStatus()>=200 && check.httpStatus()<300){
+                 if("0121".equals(check.returnCode()) || "0122".equals(check.returnCode())){
+                     facts.setStatus(row.getId(),row.getVersion(),row.getStatus(),"0121".equals(check.returnCode())?"CANCELLED":"FAILED",true);return;
+                 }
+                 if("0110".equals(check.returnCode()) && "WAITING_AUTH".equals(row.getStatus())){
+                     FlashDeliveryLinePayment claimed=facts.claim(row.getId(),"WAITING_AUTH","CONFIRMING");
+                     if(claimed!=null){confirm(claimed,credential);return;}
+                 }
+             }
+         }
+     }catch(Exception unknown){/* 查询失败仍保留原资金阻断,下一轮仅核实。 */}
+     row=payments.selectById(paymentId);
+     if(row!=null && !"PAID".equals(row.getStatus()) && row.getReconcileDeadline()!=null && !row.getReconcileDeadline().after(now))facts.setStatus(row.getId(),row.getVersion(),row.getStatus(),"MANUAL_REVIEW",false);
+ }
+ private void confirm(FlashDeliveryLinePayment row,LinePayCredential credential){
+     try {
+         LinePayResponse response=client.confirm(credential,row.getTransactionId(),row.getAmount(),row.getCurrency());
+         JSONObject tx=FlashLinePayEvidence.captured(response,row,true);
+         if(tx!=null){facts.applyPaidFact(row.getId(),tx.getString("transactionId"),FlashLinePayEvidence.amount(tx),new Date());return;}
+     }catch(Exception unknown){/* Confirm 可已经扣款,不能根据超时再次确认。 */}
+     facts.setStatus(row.getId(),row.getVersion(),"CONFIRMING","CONFIRM_UNKNOWN",false);
+ }
+ public FlashDeliveryLinePayment findByLineOrderId(String lineOrderId,String tx){
+     if(lineOrderId==null || tx==null)return null;FlashDeliveryLinePayment row=payments.selectByLineOrderId(lineOrderId);
+     return row!=null && tx.equals(row.getTransactionId())?row:null;
+ }
+ /** 平台详情只读本地事实,不发网络请求、不返回付款链接。 */
+ public FlashLinePayPaymentView localStatus(Long orderId){return view(orderId,payments.selectLatest(orderId),false);}
+ private LinePayCredential credential(FlashDeliveryLinePayment payment){
+     // 查询/退款固定旧版本,禁用或轮换不能更换历史收款主体;新请求另由意图校验当前配置。
+     InfoRiderLinePay row=credentials.getById(payment.getCredentialId());
+     if(!properties.isRiderEnvironmentMatched() || row==null || !Objects.equals(row.getEnvironment(),properties.getEnvironment().toUpperCase(Locale.ROOT)))throw fail("config.invalid");
+     return new LinePayCredential(row.getId(),row.getChannelId(),row.getChannelSecret());
+ }
+ private FlashLinePayPaymentView view(Long orderId,FlashDeliveryLinePayment row,boolean sender){
+     FlashLinePayPaymentView view=new FlashLinePayPaymentView();view.setOrderId(orderId);view.setStatus(row==null?"NOT_STARTED":row.getStatus());
+     if(row!=null){view.setPaymentId(row.getId());if(sender && "WAITING_AUTH".equals(row.getStatus()))view.setPaymentUrl(row.getPaymentUrl());FlashDeliveryLineRefund refund=refunds.selectByPaymentId(row.getId());if(refund!=null)view.setRefundStatus(refund.getStatus());}return view;
+ }
+ private static String redirect(String url,String orderId){return url+(url.contains("?")?"&":"?")+"orderId="+URLEncoder.encode(orderId,StandardCharsets.UTF_8);}
+ private static void validateRedirect(String url){if(!safeHttps(url))throw fail("config.invalid");}
+ private static boolean safeHttps(String url){try {URI uri=URI.create(url);return "https".equalsIgnoreCase(uri.getScheme()) && uri.getHost()!=null && uri.getUserInfo()==null && uri.getFragment()==null;}catch(RuntimeException invalid){return false;}}
+ private static ServiceException fail(String key){return new ServiceException(MessageUtils.message("flash.line."+key));}
+}

+ 46 - 16
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -89,6 +89,10 @@ public class FlashDeliveryApplicationService {
     private final PaymentMethodGateService paymentMethodGateService;
     private final FlashDeliveryNotificationService notificationService;
     private final UserBillingMapper userBillingMapper;
+    @org.springframework.beans.factory.annotation.Autowired
+    private com.ruoyi.app.flashdelivery.payment.FlashLinePayFactService flashLinePayFactService;
+    @org.springframework.beans.factory.annotation.Autowired
+    private com.ruoyi.app.flashdelivery.payment.FlashLinePayService flashLinePayService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -320,7 +324,7 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrderDetailView userDetail(Long userId, FlashDeliveryOrder order) {
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         FlashDeliveryOrderDetailView view = participantDetail(order, true, false);
-        view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
+        view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && Boolean.TRUE.equals(view.getCanUserCancel()));
         return view;
     }
 
@@ -465,12 +469,14 @@ public class FlashDeliveryApplicationService {
     public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
         FlashDeliveryOrder order = lockOrder(orderId);
         if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
-        if (anyPicked(requireStops(order)) || !FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
+        if (!canCancelBeforePickup(order, requireStops(order))) throw fail("flash.delivery.cancel.not.allowed");
         String reason = requiredReason(request);
         Date now = new Date();
         if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) {
             throw fail("flash.delivery.state.changed");
         }
+        // 取消和退款意图共用订单锁、同事务提交;外部退款由持久任务执行,不在这里等待网络。
+        if ("3".equals(order.getPayType())) flashLinePayFactService.ensureCancellationRefundIntent(orderId);
         writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
         // 取消成功后通知接单骑手;未接单订单由通知服务内部跳过。
         notificationService.notifyCancelled(order);
@@ -587,6 +593,8 @@ public class FlashDeliveryApplicationService {
             }
             // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。
             riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType());
+            // 列表过滤不是授权,直接传订单 ID 抢单同样复核骑手收款配置和支付选择。
+            paymentMethodGateService.assertUsable(target.getPayType(), PaymentMethodGateService.GateScope.RIDER_FLASH, null, riderId);
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {
                 throw fail("flash.delivery.order.already.accepted");
@@ -718,7 +726,15 @@ public class FlashDeliveryApplicationService {
         if (hasText(orderNo)) query.like("order_no", orderNo.trim());
         if (userId != null) query.eq("user_id", userId);
         if (riderId != null) query.eq("rider_id", riderId);
-        return orderMapper.selectPage(page(pageNum, pageSize), query);
+        IPage<FlashDeliveryOrder> result = orderMapper.selectPage(page(pageNum, pageSize), query);
+        if (!result.getRecords().isEmpty()) {
+            List<Long> ids = result.getRecords().stream().map(FlashDeliveryOrder::getId).toList();
+            // 批量读取站点,不能只按主状态猜测部分取件;避免列表 N+1 查询。
+            Map<Long, List<FlashDeliveryOrderStop>> grouped = stopMapper.selectList(new QueryWrapper<FlashDeliveryOrderStop>()
+                    .in("order_id", ids)).stream().collect(java.util.stream.Collectors.groupingBy(FlashDeliveryOrderStop::getOrderId));
+            result.getRecords().forEach(order -> order.setCanAdminCancel(canCancelBeforePickup(order, grouped.getOrDefault(order.getId(), List.of()))));
+        }
+        return result;
     }
 
     /** 平台订单审计详情:返回完整订单实体、全部凭证图片和状态日志。 */
@@ -727,8 +743,11 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView();
         view.setOrder(order);
         List<FlashDeliveryOrderStop> stationRows = stops(orderId);
+        view.setCanAdminCancel(canCancelBeforePickup(order, stationRows));
+        order.setCanAdminCancel(view.getCanAdminCancel());
+        if ("3".equals(order.getPayType())) view.setLinePayPayment(flashLinePayService.localStatus(orderId));
         view.setPickupStopCount(order.getPickupStopCount()); view.setPickedUpStopCount(order.getPickedUpStopCount());
-        view.setCanUserCancel(canUserCancel(order)); view.setCurrentTargetStopId(targetStopId(order, stationRows));
+        view.setCanUserCancel(view.getCanAdminCancel()); view.setCurrentTargetStopId(targetStopId(order, stationRows));
         view.setTargetAdjustments(adjustmentMapper.selectList(new QueryWrapper<FlashDeliveryTargetAdjustment>()
                 .eq("order_id", orderId).orderByAsc("create_time", "id")));
         view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
@@ -741,30 +760,25 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
-    /** 平台取消:任何非终态订单可取消,记录操作人与原因,用于异常履约人工收口。 */
+    /** 平台取消仅取件前允许;已有任一站取件时只能客服处理,不能取消并自动退款。 */
     @Transactional
     public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) {
         FlashDeliveryOrder order = lockOrder(orderId);
         if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
         String reason = requiredReason(request);
         List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (!canCancelBeforePickup(order, rows)) throw fail("flash.delivery.cancel.not.allowed");
         if (request.getProblemStopId() == null || !hasText(request.getHandlingResult()) || tooLong(request.getHandlingResult(), 500)) {
             throw fail("flash.delivery.intervention.required");
         }
         requireStop(rows, request.getProblemStopId());
-        boolean collected = anyPicked(rows);
-        if ((collected && !hasText(request.getPickedGoodsDisposition())) || tooLong(request.getPickedGoodsDisposition(), 500)) {
-            throw fail("flash.delivery.handoff.required");
-        }
-        List<String> proofs = validateProofUrls(request.getHandoffImageUrls(), collected);
         Date now = new Date();
-        // 关闭异常订单前保存介入结果和物品去向;凭证写入失败时整笔事务回滚。
+        // 保留取件前的问题站和处理结果;旧物品交接数据仍可在历史详情中查看。
         UpdateWrapper<FlashDeliveryOrder> audit = new UpdateWrapper<FlashDeliveryOrder>().eq("id", orderId)
-                .set("problem_stop_id", request.getProblemStopId()).set("handling_result", request.getHandlingResult().trim())
-                .set("picked_goods_disposition", trimToNull(request.getPickedGoodsDisposition()));
+                .set("problem_stop_id", request.getProblemStopId()).set("handling_result", request.getHandlingResult().trim());
         if (orderMapper.update(null, audit) != 1) throw fail("flash.delivery.state.changed");
-        saveStationImages(orderId, request.getProblemStopId(), "HANDOFF", "ADMIN", adminId, proofs, now);
         if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) throw fail("flash.delivery.state.changed");
+        if ("3".equals(order.getPayType())) flashLinePayFactService.ensureCancellationRefundIntent(orderId);
         writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
         // 平台取消同样只通知接单骑手;寄件用户按规格不推送。
         notificationService.notifyCancelled(order);
@@ -817,6 +831,8 @@ public class FlashDeliveryApplicationService {
         requireRider(riderId);
         FlashDeliveryOrder order = requireOrder(orderId);
         if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        // LINE Pay 只认网关到账事实,骑手不能用线下收款确认接口伪造线上支付。
+        if ("3".equals(order.getPayType())) throw fail("flash.line.manual.forbidden");
         if (!DELIVERED.equals(order.getStatus()) && !COMPLETED.equals(order.getStatus())) {
             throw fail("flash.delivery.payment.confirm.not.allowed");
         }
@@ -1030,6 +1046,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
         if (!hideSensitiveFields) view.setOrderVersion(order.getVersion());
         List<FlashDeliveryOrderStop> stationRows = stops(order.getId());
+        view.setCanUserCancel(canCancelBeforePickup(order, stationRows));
         List<FlashDeliveryOrderImage> stationImages = hideSensitiveFields ? List.of() : imageMapper.selectList(currentRead(
                 new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
         view.setStops(stopViews(stationRows, !hideSensitiveFields, stationImages));
@@ -1745,7 +1762,7 @@ public class FlashDeliveryApplicationService {
     }
 
     private boolean anyPicked(List<FlashDeliveryOrderStop> rows) {
-        return rows.stream().anyMatch(s -> "PICKUP".equals(s.getStopType()) && "PICKED_UP".equals(s.getStatus()));
+        return rows.stream().anyMatch(s -> "PICKUP".equals(s.getStopType()) && ("PICKED_UP".equals(s.getStatus()) || s.getPickedUpAt() != null));
     }
 
     private boolean allPicked(List<FlashDeliveryOrderStop> rows) {
@@ -1848,6 +1865,8 @@ public class FlashDeliveryApplicationService {
         if ("PICKED_UP".equals(stop.getStatus())) return;
         if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed");
         if (!Objects.equals(stopId, targetStopId(order, rows))) throw fail("flash.delivery.target.mismatch");
+        // 每站取件都在同一订单锁内验证可信资金事实,现金/转账保持原履约路径。
+        if ("3".equals(order.getPayType())) flashLinePayFactService.assertPickupPaid(order);
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
         Date now = new Date();
         stop.setStatus("PICKED_UP"); stop.setPickedUpAt(now);
@@ -1887,10 +1906,21 @@ public class FlashDeliveryApplicationService {
     }
 
     private boolean canUserCancel(FlashDeliveryOrder order) {
-        return FlashDeliveryStateMachine.canUserCancel(order.getStatus())
+        // 卡片用汇总字段显示提示,详情复用已读站点;取消接口始终在锁内核实真实站点。
+        return FlashDeliveryStateMachine.canUserCancel(order.getStatus()) && order.getPickedUpAt() == null
                 && Integer.valueOf(0).equals(order.getPickedUpStopCount());
     }
 
+    /** 缺失/不一致站点安全拒绝;不能把 ACCEPTED、汇总计数或旧数据当作未取件证据。 */
+    private boolean canCancelBeforePickup(FlashDeliveryOrder order, List<FlashDeliveryOrderStop> rows) {
+        if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus()) || order.getPickedUpAt() != null
+                || order.getPickedUpStopCount() != null && order.getPickedUpStopCount() > 0 || rows == null) return false;
+        List<FlashDeliveryOrderStop> pickups = pickupStops(rows);
+        return !pickups.isEmpty() && Objects.equals(order.getPickupStopCount(), pickups.size())
+                && rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() == 1 && !anyPicked(rows)
+                && pickups.stream().allMatch(s -> "PENDING".equals(s.getStatus()));
+    }
+
 
     /** 报价中间结果:命中的运价配置、路线距离和费用明细。 */
     private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route,

+ 3 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java

@@ -9,7 +9,7 @@ import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
 public final class FlashDeliveryStateMachine {
     private FlashDeliveryStateMachine() { }
 
-    /** 判断状态流转是否合法:正常链路逐级推进,非终态可转已取消由平台收口。 */
+    /** 正常链路逐级推进;取消仅允许取件前,站点事实由业务入口再次校验。 */
     public static boolean canTransition(String from, String to) {
         return WAITING_ACCEPTANCE.equals(from) && ACCEPTED.equals(to)
                 || ACCEPTED.equals(from) && PICKED_UP.equals(to)
@@ -24,7 +24,7 @@ public final class FlashDeliveryStateMachine {
     }
 
     public static boolean canAdminCancel(String status) {
-        // 平台可取消任何非终态订单,用于异常履约人工收口。
-        return !COMPLETED.equals(status) && !CANCELLED.equals(status);
+        // 取件后的异常只能由客服处理,不能通过取消接口关闭配送或自动退款。
+        return WAITING_ACCEPTANCE.equals(status) || ACCEPTED.equals(status);
     }
 }

+ 53 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/RiderLinePayController.java

@@ -0,0 +1,53 @@
+package com.ruoyi.app.pay;
+import com.alibaba.fastjson2.*;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.dto.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import java.util.Locale;
+import java.util.UUID;
+
+/** 平台骑手收款配置。Secret 仅授权详情可读,保存请求禁止操作日志采集原文。 */
+@RestController @RequestMapping("/system/riderLinePay")
+public class RiderLinePayController extends BaseController {
+ private final IInfoRiderLinePayService service;
+ private final LinePayClient client;
+ private final String environment;
+ private final LinePayProperties properties;
+ public RiderLinePayController(IInfoRiderLinePayService service,LinePayClient client,
+         LinePayProperties properties){this.service=service;this.client=client;this.properties=properties;this.environment=properties.getEnvironment().toUpperCase(Locale.ROOT);}
+ @PreAuthorize("@ss.hasPermi('infouser:riderLinePay:list')") @GetMapping("/list")
+ public TableDataInfo list(@RequestParam(required=false) String userName){startPage();return getDataTable(service.selectRiderList(userName));}
+ @PreAuthorize("@ss.hasPermi('infouser:riderLinePay:query')") @GetMapping("/{riderId}")
+ public AjaxResult detail(@PathVariable Long riderId){return success(service.selectRiderDetail(riderId));}
+ @PreAuthorize("@ss.hasPermi('infouser:riderLinePay:saveCredentials')") @PutMapping("/saveCredentials")
+ public AjaxResult saveCredentials(@RequestBody RiderLinePayCredentialDto dto){
+     if(!properties.isRiderEnvironmentMatched())return error(MessageUtils.message("flash.line.config.invalid"));
+     if(dto==null || dto.getChannelId()==null || dto.getChannelId().isBlank() || dto.getChannelId().length()>100
+             || dto.getChannelSecret()==null || dto.getChannelSecret().isBlank() || dto.getChannelSecret().length()>256)
+         return error(MessageUtils.message("line.pay.credential.required"));
+     // 先校验收款身份,不能让无效账号触发网关调用;探测只读且使用随机不存在的订单。
+     service.assertRider(dto.getRiderId());
+     LinePayResponse response;
+     try { response=client.retrieveByOrderId(new LinePayCredential(null,dto.getChannelId().trim(),dto.getChannelSecret()),"FLVERIFY"+UUID.randomUUID().toString().replace("-","")); }
+     catch(Exception unknown){return error(MessageUtils.message("line.pay.credential.verify.unknown"));}
+     Object info;
+     try { info=response==null || response.rawBody()==null?null:JSON.parseObject(response.rawBody()).get("info"); }
+     catch(RuntimeException malformed){return error(MessageUtils.message("line.pay.credential.verify.unknown"));}
+     if(response==null || response.httpStatus()<200 || response.httpStatus()>=300
+             || !("1150".equals(response.returnCode()) || response.isSuccess() && info instanceof JSONArray && ((JSONArray)info).isEmpty()))
+         return error(MessageUtils.message("line.pay.credential.invalid"));
+     service.saveVerifiedCredential(dto,environment,response.returnCode(),null);
+     return success(MessageUtils.message("line.pay.credential.enabled"));
+ }
+ @PreAuthorize("@ss.hasPermi('infouser:riderLinePay:toggleEnable')") @PutMapping("/toggleEnable")
+ public AjaxResult toggleEnable(@RequestBody RiderLinePayToggleDto dto){
+     if(dto==null || dto.getRiderId()==null || dto.getEnabled()==null) return error(MessageUtils.message("line.pay.credential.required"));
+     return toAjax(service.setCurrentEnabled(dto.getRiderId(),dto.getEnabled()));
+ }
+}

+ 43 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/task/FlashLinePayReconcileTask.java

@@ -0,0 +1,43 @@
+package com.ruoyi.app.task;
+import com.ruoyi.app.flashdelivery.payment.*;
+import com.ruoyi.system.mapper.flash.*;
+import org.redisson.api.RedissonClient;
+import org.redisson.api.RLock;
+import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.Date;
+/** 数据库驱动恢复,不依赖浏览器回跳;资金未知时由服务只读核实,不重复扣款/退款。 */
+@Component
+public class FlashLinePayReconcileTask {
+ private static final Logger log=LoggerFactory.getLogger(FlashLinePayReconcileTask.class);
+ private final FlashDeliveryLinePaymentMapper payments;
+ private final FlashDeliveryLineRefundMapper refunds;
+ private final FlashLinePayService service;
+ private final FlashLinePayRefundService refundService;
+ private final RedissonClient redisson;
+ @Value("${line-pay.reconcile.batch-size:20}") private int batchSize=20;
+ @Value("${line-pay.reconcile.round-budget-seconds:45}") private long roundBudgetSeconds=45;
+ public FlashLinePayReconcileTask(FlashDeliveryLinePaymentMapper p,FlashDeliveryLineRefundMapper r,FlashLinePayService s,FlashLinePayRefundService f,RedissonClient c){payments=p;refunds=r;service=s;refundService=f;redisson=c;}
+ @Scheduled(fixedDelayString="${line-pay.reconcile.fixed-delay-ms:60000}",initialDelayString="${line-pay.reconcile.initial-delay-ms:60000}")
+ public void reconcile(){
+     RLock lock=redisson.getLock("lock:flash-line-pay:reconcile");if(!lock.tryLock())return;
+     try {
+         long start=System.currentTimeMillis();int limit=Math.min(100,Math.max(1,batchSize));
+         for(var row:payments.selectDue(new Date(),limit)){
+             if(exceeded(start))break;
+             try{service.reconcile(row.getId(),"TASK");}catch(Exception unavailable){log.warn("Flash LINE payment reconcile failed, paymentId={}",row.getId());}
+         }
+         // 先核实迟到付款,再扫描新生成的退款意图;退款保留独立预算,防止被支付批次饿死。
+         start=System.currentTimeMillis();
+         for(var row:refunds.selectDue(new Date(),limit)){
+             if(exceeded(start))break;
+             try{refundService.reconcile(row.getId(),"TASK");}catch(Exception unavailable){log.warn("Flash LINE refund reconcile failed, refundId={}",row.getId());}
+         }
+     }catch(Exception unavailable){log.warn("Flash LINE reconcile round failed");}
+     finally{if(lock.isHeldByCurrentThread())lock.unlock();}
+ }
+ private boolean exceeded(long start){return System.currentTimeMillis()-start>=Math.max(1,roundBudgetSeconds)*1000L;}
+}

+ 8 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayClient.java

@@ -44,6 +44,12 @@ public class LinePayClient {
     }
 
     public LinePayResponse request(LinePayCredential credential, LinePayRequest request) throws Exception {
+        return request(credential, request, properties.getConfirmUrl(), properties.getCancelUrl());
+    }
+
+    /** 闪送显式提供服务端回跳地址;原外卖双参数入口保持原配置,不全局替换 URL。 */
+    public LinePayResponse request(LinePayCredential credential, LinePayRequest request,
+                                   String confirmUrl, String cancelUrl) throws Exception {
         validateRequest(request);
         JSONObject product = new JSONObject();
         product.put("name", request.productName());
@@ -55,8 +61,8 @@ public class LinePayClient {
         packageValue.put("products", new JSONArray().fluentAdd(product));
 
         JSONObject redirectUrls = new JSONObject();
-        redirectUrls.put("confirmUrl", require(properties.getConfirmUrl(), "confirmUrl"));
-        redirectUrls.put("cancelUrl", require(properties.getCancelUrl(), "cancelUrl"));
+        redirectUrls.put("confirmUrl", require(confirmUrl, "confirmUrl"));
+        redirectUrls.put("cancelUrl", require(cancelUrl, "cancelUrl"));
 
         JSONObject payment = new JSONObject();
         payment.put("capture", true);

+ 10 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayProperties.java

@@ -4,7 +4,7 @@ import lombok.Data;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.stereotype.Component;
 
-/** LINE Pay environment and redirect configuration. Credentials remain store-scoped in DB. */
+/** LINE Pay environment and redirects; store/rider credentials remain independently versioned in DB. */
 @Data
 @Component
 @ConfigurationProperties(prefix = "line-pay")
@@ -13,6 +13,15 @@ public class LinePayProperties {
     private String baseUrl = "https://sandbox-api-pay.line.me";
     private String confirmUrl;
     private String cancelUrl;
+    /** 闪送骑手收款独立回跳,不能把闪送流水交给外卖确认入口。 */
+    private String flashConfirmUrl;
+    private String flashCancelUrl;
     private String appPackageName = "com.twanmsdyh.app";
+
+    /** 骑手配置/资金调用绑定真实网关,防止 Sandbox 标签实际向生产扣款;不改变原外卖 SDK 行为。 */
+    public boolean isRiderEnvironmentMatched() {
+        return "sandbox".equalsIgnoreCase(environment) && "https://sandbox-api-pay.line.me".equals(baseUrl)
+                || "production".equalsIgnoreCase(environment) && "https://api-pay.line.me".equals(baseUrl);
+    }
 }
 

+ 3 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -259,6 +259,9 @@ line-pay:
   base-url: ${LINE_PAY_BASE_URL:https://sandbox-api-pay.line.me}
   confirm-url: ${LINE_PAY_CONFIRM_URL:https://foodieapi.waimai-paotui.com/pay/line/confirm}
   cancel-url: ${LINE_PAY_CANCEL_URL:https://foodieapi.waimai-paotui.com/pay/line/cancel}
+  # 骑手闪送独立流水,不能送回外卖确认入口;上线前配置公网 HTTPS 地址。
+  flash-confirm-url: ${LINE_PAY_FLASH_CONFIRM_URL:}
+  flash-cancel-url: ${LINE_PAY_FLASH_CANCEL_URL:}
   reconcile:
     fixed-delay-ms: 60000
     initial-delay-ms: 60000

+ 10 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -1,4 +1,14 @@
 #错误消息
+# 019 flash rider LINE Pay
+flash.line.rider.invalid=Tài khoản nhận tiền phải là tài xế đang hoạt động
+flash.line.accept.required=Chờ tài xế nhận đơn trước khi thanh toán
+flash.line.pickup.unpaid=Phải xác minh thanh toán LINE Pay trước khi lấy hàng
+flash.line.manual.forbidden=Tài xế không thể xác nhận LINE Pay thủ công
+flash.line.amount.invalid=Số tiền không hợp lệ hoặc vượt giới hạn
+flash.line.unknown=Đang xác minh thanh toán, không thanh toán lại
+flash.line.config.invalid=Thông tin LINE Pay hoặc trang chuyển hướng chưa sẵn sàng
+flash.line.not.found=Không tìm thấy đơn hoặc thanh toán
+flash.line.cancel.picked=Không thể hủy sau khi đã lấy hàng ở bất kỳ điểm nào
 not.null=* 必須填寫
 user.jcaptcha.error=驗證碼錯誤
 user.jcaptcha.expire=驗證碼已失效

+ 10 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -1,4 +1,14 @@
 #错误消息
+# 019 flash rider LINE Pay
+flash.line.rider.invalid=The payee must be an active rider account
+flash.line.accept.required=Wait for rider acceptance before paying
+flash.line.pickup.unpaid=LINE Pay payment must be verified before pickup
+flash.line.manual.forbidden=Riders cannot manually confirm LINE Pay receipts
+flash.line.amount.invalid=Invalid payment amount or channel limit exceeded
+flash.line.unknown=Payment is being verified; do not pay again
+flash.line.config.invalid=LINE Pay credentials or redirect configuration are unavailable
+flash.line.not.found=Order or payment not found
+flash.line.cancel.picked=Orders cannot be cancelled after any pickup
 not.null=* required
 user.jcaptcha.error=Verification code error
 user.jcaptcha.expire=The verification code has expired

+ 10 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -1,4 +1,14 @@
 # ข้อความแสดงข้อผิดพลาด
+# 019 flash rider LINE Pay
+flash.line.rider.invalid=บัญชีผู้รับเงินต้องเป็นไรเดอร์ที่ใช้งานอยู่
+flash.line.accept.required=รอไรเดอร์รับงานก่อนชำระเงิน
+flash.line.pickup.unpaid=ต้องยืนยันการชำระ LINE Pay ก่อนรับพัสดุ
+flash.line.manual.forbidden=ไรเดอร์ยืนยัน LINE Pay เองไม่ได้
+flash.line.amount.invalid=ยอดชำระไม่ถูกต้องหรือเกินวงเงิน
+flash.line.unknown=กำลังตรวจสอบการชำระ กรุณาอย่าจ่ายซ้ำ
+flash.line.config.invalid=ข้อมูล LINE Pay หรือหน้ากลับไม่พร้อมใช้งาน
+flash.line.not.found=ไม่พบคำสั่งซื้อหรือการชำระเงิน
+flash.line.cancel.picked=รับพัสดุแล้วไม่สามารถยกเลิกได้
 not.null=* จำเป็นต้องกรอก
 user.jcaptcha.error=รหัสยืนยันไม่ถูกต้อง
 user.jcaptcha.expire=รหัสยืนยันหมดอายุแล้ว

+ 10 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -1,4 +1,14 @@
 #错误消息
+# 019 flash rider LINE Pay
+flash.line.rider.invalid=收款账号必须是正常且未删除的骑手
+flash.line.accept.required=请先等待骑手接单后再支付
+flash.line.pickup.unpaid=LINE Pay 未确认支付成功,暂不能取件
+flash.line.manual.forbidden=LINE Pay 收款不能由骑手手工确认
+flash.line.amount.invalid=支付金额无效或超出渠道限制
+flash.line.unknown=支付结果正在核实,请勿重复付款
+flash.line.config.invalid=LINE Pay 配置未启用或回跳配置无效
+flash.line.not.found=订单或支付记录不存在
+flash.line.cancel.picked=任意站点取件后不能取消订单
 not.null=* 必须填写
 user.jcaptcha.error=验证码错误
 user.jcaptcha.expire=验证码已失效

+ 10 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -1,4 +1,14 @@
 #错误消息
+# 019 flash rider LINE Pay
+flash.line.rider.invalid=收款帳號必須是正常且未刪除的騎手
+flash.line.accept.required=請先等待騎手接單後再付款
+flash.line.pickup.unpaid=LINE Pay 尚未確認付款成功,暫不能取件
+flash.line.manual.forbidden=LINE Pay 收款不能由騎手手動確認
+flash.line.amount.invalid=付款金額無效或超出渠道限制
+flash.line.unknown=付款結果正在核實,請勿重複付款
+flash.line.config.invalid=LINE Pay 設定未啟用或返回設定無效
+flash.line.not.found=訂單或付款記錄不存在
+flash.line.cancel.picked=任意站點取件後不能取消訂單
 not.null=* 必須填寫
 user.jcaptcha.error=驗證碼錯誤
 user.jcaptcha.expire=驗證碼已失效

+ 27 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayControllerTest.java

@@ -0,0 +1,27 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.flash.FlashDeliveryLinePayment;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+class FlashLinePayControllerTest {
+ @Test void forgedReturnDoesNotConfirmPayment(){
+     FlashLinePayService service=mock(FlashLinePayService.class);FlashLinePayAsyncService async=mock(FlashLinePayAsyncService.class);
+     try(var messages=mockStatic(MessageUtils.class)){
+         messages.when(()->MessageUtils.message("flash.line.unknown")).thenReturn("<unverified>");
+         var controller=new FlashLinePayReturnController(service,async,new FlashLinePayReturnPageRenderer());
+         var response=controller.confirm("forged","123");verifyNoInteractions(async);
+         assertTrue(response.getBody().contains("&lt;unverified&gt;"));assertFalse(response.getBody().contains("orderId=123"));
+         assertEquals("no-store",response.getHeaders().getFirst("Cache-Control"));
+     }
+ }
+ @Test void validBrowserCancelOnlyQueuesVerification(){
+     FlashLinePayService service=mock(FlashLinePayService.class);FlashLinePayAsyncService async=mock(FlashLinePayAsyncService.class);
+     FlashDeliveryLinePayment payment=new FlashDeliveryLinePayment();payment.setId(22L);payment.setOrderId(1L);when(service.findByLineOrderId("FLP","123")).thenReturn(payment);
+     try(var messages=mockStatic(MessageUtils.class)){
+         messages.when(()->MessageUtils.message("flash.line.unknown")).thenReturn("unverified");
+         var response=new FlashLinePayReturnController(service,async,new FlashLinePayReturnPageRenderer()).cancel("FLP","123");
+         verify(async).reconcile(22L,"BROWSER");assertTrue(response.getBody().contains("orderSource=FLASH&amp;orderId=1"));verify(service,never()).reconcile(anyLong(),anyString());
+     }
+ }
+}

+ 16 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayEvidenceTest.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.alibaba.fastjson2.*;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.system.domain.flash.FlashDeliveryLinePayment;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+class FlashLinePayEvidenceTest {
+ @Test void confirmedPaymentRetainsExactTransactionId(){JSONObject tx=transaction();LinePayResponse r=response(tx.toJSONString());assertNotNull(FlashLinePayEvidence.captured(r,payment(),true));assertEquals("2026081200000000001",FlashLinePayEvidence.captured(r,payment(),true).getString("transactionId"));assertEquals(66,FlashLinePayEvidence.amount(tx));}
+ @Test void forgedOrderOrTransactionCannotBecomePaymentFact(){JSONObject tx=transaction();tx.put("orderId","another-order");assertNull(FlashLinePayEvidence.captured(response(tx.toJSONString()),payment(),true));tx=transaction();tx.put("transactionId","wrong");assertNull(FlashLinePayEvidence.captured(response(tx.toJSONString()),payment(),true));}
+ @Test void fractionalGatewayAmountNotTruncated(){JSONObject tx=transaction();tx.put("payInfo",JSON.parseArray("[{\"amount\":66.5}]"));assertNull(FlashLinePayEvidence.captured(response(tx.toJSONString()),payment(),true));}
+ @Test void retrieveRequiresCapturedPaymentType(){JSONObject tx=transaction();tx.put("transactionType","AUTHORIZATION");assertNull(FlashLinePayEvidence.captured(response("["+tx+"]"),payment(),false));}
+ @Test void fullRefundEvidenceRequiresAmountAndTransaction(){JSONObject tx=transaction();tx.put("refundList",JSON.parseArray("[{\"refundAmount\":-66,\"refundTransactionId\":\"9876543210123456789\"}]"));assertEquals("9876543210123456789",FlashLinePayEvidence.refundedId(tx,66));tx.put("refundList",JSON.parseArray("[{\"refundAmount\":-6,\"refundTransactionId\":\"987\"}]"));assertNull(FlashLinePayEvidence.refundedId(tx,66));}
+ static JSONObject transaction(){return JSON.parseObject("{\"orderId\":\"FLP-test\",\"transactionId\":\"2026081200000000001\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\",\"payInfo\":[{\"method\":\"BALANCE\",\"amount\":66}]}");}
+ static FlashDeliveryLinePayment payment(){FlashDeliveryLinePayment p=new FlashDeliveryLinePayment();p.setLineOrderId("FLP-test");p.setTransactionId("2026081200000000001");p.setCurrency("TWD");p.setAmount(66);return p;}
+ static LinePayResponse response(String info){String raw="{\"returnCode\":\"0000\",\"info\":"+info+"}";JSONObject root=JSON.parseObject(raw);return new LinePayResponse(200,"0000","OK",null,root.get("info") instanceof JSONObject?root.getJSONObject("info"):null,raw);}
+}

+ 93 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayFactServiceTest.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class FlashLinePayFactServiceTest {
+ private final FlashDeliveryOrderMapper orders=mock(FlashDeliveryOrderMapper.class);
+ private final FlashDeliveryLinePaymentMapper payments=mock(FlashDeliveryLinePaymentMapper.class);
+ private final FlashDeliveryLineRefundMapper refunds=mock(FlashDeliveryLineRefundMapper.class);
+ private final IInfoRiderLinePayService credentials=mock(IInfoRiderLinePayService.class);
+ private final FlashLinePayFactService facts=new FlashLinePayFactService(orders,payments,refunds,credentials);
+ private MockedStatic<MessageUtils> messages;
+ @BeforeEach void setup(){messages=mockStatic(MessageUtils.class);messages.when(()->MessageUtils.message(anyString())).thenAnswer(i->i.getArgument(0));}
+ @AfterEach void close(){messages.close();}
+ @Test void unacceptedOrderCannotPay(){FlashDeliveryOrder o=order();o.setStatus("WAITING_ACCEPTANCE");when(orders.selectByIdForUpdate(1L)).thenReturn(o);assertThrows(ServiceException.class,()->facts.createIntent(8L,1L));verify(payments,never()).insert(any(FlashDeliveryLinePayment.class));}
+ @Test void crossUserCannotPay(){when(orders.selectByIdForUpdate(1L)).thenReturn(order());assertThrows(ServiceException.class,()->facts.createIntent(9L,1L));}
+ @Test void serverAmountOverflowRejected(){FlashDeliveryOrder o=order();o.setAmount(2147483648L);when(orders.selectByIdForUpdate(1L)).thenReturn(o);assertThrows(ServiceException.class,()->facts.createIntent(8L,1L));}
+ @Test void serverAmountAndRiderAreUsed(){when(orders.selectByIdForUpdate(1L)).thenReturn(order());InfoRiderLinePay c=new InfoRiderLinePay();c.setId(45L);when(credentials.getEnabledCurrent(10L)).thenReturn(c);when(payments.insert(any(FlashDeliveryLinePayment.class))).thenAnswer(i->{((FlashDeliveryLinePayment)i.getArgument(0)).setId(22L);return 1;});FlashDeliveryLinePayment p=facts.createIntent(8L,1L);assertNotNull(p);assertEquals(66,p.getAmount());assertEquals(10L,p.getRiderId());assertEquals(45L,p.getCredentialId());assertEquals("TWD",p.getCurrency());}
+ @Test void duplicateCreateUsesExistingAttempt(){when(orders.selectByIdForUpdate(1L)).thenReturn(order());FlashDeliveryLinePayment p=new FlashDeliveryLinePayment();p.setActiveOrderId(1L);p.setId(22L);p.setStatus("WAITING_AUTH");when(payments.selectLatestForUpdate(1L)).thenReturn(p);assertSame(p,facts.createIntent(8L,1L));verify(payments,never()).insert(any(FlashDeliveryLinePayment.class));}
+ @Test void manualReceiptIsNotTrustedPickupPayment(){FlashDeliveryOrder o=order();o.setPaymentStatus(1);assertThrows(ServiceException.class,()->facts.assertPickupPaid(o));}
+ @Test void latePaymentOnCancelledOrderCreatesRefund(){
+     FlashDeliveryOrder o=order();o.setStatus("CANCELLED");
+     FlashDeliveryLinePayment p=new FlashDeliveryLinePayment();p.setId(22L);p.setOrderId(1L);p.setRiderId(10L);p.setAmount(66);p.setCurrency("TWD");p.setStatus("CONFIRM_UNKNOWN");p.setVersion(0L);p.setTransactionId("2026081200000000001");
+     when(payments.selectById(22L)).thenReturn(p);when(payments.selectForUpdate(22L)).thenReturn(p);when(payments.selectLatestForUpdate(1L)).thenReturn(p);when(orders.selectByIdForUpdate(1L)).thenReturn(o);
+     when(payments.updateVersioned(any(),eq(0L))).thenReturn(1);when(orders.update(isNull(),any())).thenReturn(1);
+     when(refunds.insert(any(FlashDeliveryLineRefund.class))).thenReturn(1);
+     assertTrue(facts.applyPaidFact(22L,"2026081200000000001",66,new java.util.Date()));
+     assertEquals("CANCELLED",o.getStatus());
+     org.mockito.ArgumentCaptor<FlashDeliveryLineRefund> refund=org.mockito.ArgumentCaptor.forClass(FlashDeliveryLineRefund.class);
+     verify(refunds).insert(refund.capture());assertEquals(66,refund.getValue().getAmount());assertEquals(22L,refund.getValue().getPaymentId());assertEquals("PAID",p.getStatus());
+ }
+ @Test void cancelledOrderCannotClaimNewConfirm(){FlashDeliveryOrder o=order();o.setStatus("CANCELLED");FlashDeliveryLinePayment p=new FlashDeliveryLinePayment();p.setId(22L);p.setOrderId(1L);p.setStatus("WAITING_AUTH");p.setVersion(0L);when(payments.selectById(22L)).thenReturn(p);when(payments.selectForUpdate(22L)).thenReturn(p);when(orders.selectByIdForUpdate(1L)).thenReturn(o);assertNull(facts.claim(22L,"WAITING_AUTH","CONFIRMING"));verify(payments,never()).updateVersioned(any(),any());}
+ @Test void disabledCredentialsCannotClaimAnUnsentRequest(){
+     FlashDeliveryLinePayment p=preparedPayment();p.setStatus("CREATED");
+     assertNull(facts.claim(22L,"CREATED","REQUESTING"));
+     verify(payments,never()).updateVersioned(any(),any());
+ }
+ @Test void gatewayFullRefundBlocksPickupAtomically(){
+     FlashDeliveryLinePayment p=preparedPayment();
+     when(refunds.insert(any(FlashDeliveryLineRefund.class))).thenAnswer(i->{FlashDeliveryLineRefund r=i.getArgument(0);r.setId(77L);when(refunds.selectByPaymentIdForUpdate(22L)).thenReturn(r);return 1;});
+     facts.applyGatewayFact(22L,p.getTransactionId(),66,new java.util.Date(),"987",true);
+     assertThrows(ServiceException.class,()->facts.assertPickupPaid(order()));
+     org.mockito.ArgumentCaptor<FlashDeliveryLineRefund> captured=org.mockito.ArgumentCaptor.forClass(FlashDeliveryLineRefund.class);
+     verify(refunds).insert(captured.capture());assertEquals("REFUNDED",captured.getValue().getStatus());
+ }
+ @Test void partialRefundRequiresManualReviewInsteadOfAllowingPickup(){
+     FlashDeliveryLinePayment p=preparedPayment();
+     when(refunds.insert(any(FlashDeliveryLineRefund.class))).thenReturn(1);
+     facts.applyGatewayFact(22L,p.getTransactionId(),66,new java.util.Date(),null,true);
+     assertEquals("MANUAL_REVIEW",p.getStatus());assertThrows(ServiceException.class,()->facts.assertPickupPaid(order()));
+ }
+ private FlashDeliveryLinePayment preparedPayment(){
+     FlashDeliveryLinePayment p=FlashLinePayEvidenceTest.payment();p.setId(22L);p.setOrderId(1L);p.setRiderId(10L);p.setCredentialId(45L);p.setVersion(0L);p.setStatus("WAITING_AUTH");
+     when(payments.selectById(22L)).thenReturn(p);when(payments.selectForUpdate(22L)).thenReturn(p);when(payments.selectLatestForUpdate(1L)).thenReturn(p);when(orders.selectByIdForUpdate(1L)).thenReturn(order());
+     when(payments.updateVersioned(any(),anyLong())).thenReturn(1);when(orders.update(isNull(),any())).thenReturn(1);return p;
+ }
+ @Test void paidCancellationCreatesOnlyOneFullRefundIntent(){
+     FlashDeliveryLinePayment p=preparedPayment();p.setCapturedAmount(66);p.setStatus("PAID");
+     when(refunds.insert(any(FlashDeliveryLineRefund.class))).thenAnswer(i->{FlashDeliveryLineRefund refund=i.getArgument(0);when(refunds.selectByPaymentIdForUpdate(22L)).thenReturn(refund);return 1;});
+     facts.ensureCancellationRefundIntent(1L);facts.ensureCancellationRefundIntent(1L);
+     var refund=org.mockito.ArgumentCaptor.forClass(FlashDeliveryLineRefund.class);verify(refunds,times(1)).insert(refund.capture());assertEquals(66,refund.getValue().getAmount());
+ }
+ @Test void refundFactDoesNotErasePaidFact(){
+     FlashDeliveryLinePayment p=preparedPayment();p.setStatus("PAID");p.setCapturedAmount(66);
+     FlashDeliveryLineRefund r=new FlashDeliveryLineRefund();r.setId(77L);r.setPaymentId(22L);r.setVersion(0L);r.setStatus("UNKNOWN");
+     when(refunds.selectById(77L)).thenReturn(r);when(refunds.selectForUpdate(77L)).thenReturn(r);when(refunds.updateVersioned(any(),anyLong())).thenReturn(1);
+     facts.applyRefundedFact(77L,"987",new java.util.Date());assertEquals("PAID",p.getStatus());assertEquals(66,p.getCapturedAmount());assertEquals("REFUNDED",r.getStatus());
+ }
+ @Test void pickupUsesCurrentRefundFactsInsteadOfTheEarlierTransactionSnapshot(){
+     FlashDeliveryLinePayment paid=FlashLinePayEvidenceTest.payment();paid.setId(22L);paid.setStatus("PAID");paid.setPaidAt(new java.util.Date());paid.setCapturedAmount(66);paid.setRiderId(10L);
+     FlashDeliveryLineRefund completed=new FlashDeliveryLineRefund();completed.setStatus("REFUNDED");
+     // Model REPEATABLE READ: ordinary selects retain PAID/no refund; locking reads see the committed refund.
+     FlashDeliveryLinePaymentMapper currentPayments=mock(FlashDeliveryLinePaymentMapper.class,i->i.getMethod().getName().startsWith("selectLatest")?paid:org.mockito.Answers.RETURNS_DEFAULTS.answer(i));
+     FlashDeliveryLineRefundMapper currentRefunds=mock(FlashDeliveryLineRefundMapper.class,i->i.getMethod().getName().equals("selectByPaymentIdForUpdate")?completed:org.mockito.Answers.RETURNS_DEFAULTS.answer(i));
+     FlashLinePayFactService gate=new FlashLinePayFactService(orders,currentPayments,currentRefunds,credentials);
+     assertThrows(ServiceException.class,()->gate.assertPickupPaid(order()));
+ }
+ @Test void expiredProbeCannotDowngradeAnAlreadyRefundedFact(){
+     preparedPayment();FlashDeliveryLineRefund r=new FlashDeliveryLineRefund();r.setId(77L);r.setPaymentId(22L);r.setVersion(3L);r.setStatus("REFUNDED");r.setRefundTransactionId("987");
+     when(refunds.selectById(77L)).thenReturn(r);when(refunds.selectForUpdate(77L)).thenReturn(r);when(refunds.updateVersioned(any(),anyLong())).thenReturn(1);
+     facts.refundStatus(77L,3L,"REFUNDED","MANUAL_REVIEW");
+     assertEquals("REFUNDED",r.getStatus());assertEquals("987",r.getRefundTransactionId());
+ }
+ static FlashDeliveryOrder order(){FlashDeliveryOrder o=new FlashDeliveryOrder();o.setId(1L);o.setUserId(8L);o.setRiderId(10L);o.setStatus("ACCEPTED");o.setAmount(66L);o.setCurrency("TWD");o.setPayType("3");o.setPickedUpStopCount(0);return o;}
+}

+ 50 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRecoveryFairnessTest.java

@@ -0,0 +1,50 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.app.task.FlashLinePayReconcileTask;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.*;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class FlashLinePayRecoveryFairnessTest {
+ @Test void unresolvedFirstBatchDoesNotStarveLaterPaymentsOrRefunds() throws Exception {
+     FlashDeliveryOrderMapper orders=mock(FlashDeliveryOrderMapper.class);
+     FlashDeliveryLinePaymentMapper payments=mock(FlashDeliveryLinePaymentMapper.class);
+     FlashDeliveryLineRefundMapper refunds=mock(FlashDeliveryLineRefundMapper.class);
+     IInfoRiderLinePayService credentials=mock(IInfoRiderLinePayService.class);
+     LinePayClient client=mock(LinePayClient.class);
+     Map<Long,FlashDeliveryLinePayment> paidRows=new LinkedHashMap<>();Map<Long,FlashDeliveryLineRefund> refundRows=new LinkedHashMap<>();
+     for(long id=1;id<=21;id++){
+         FlashDeliveryLinePayment p=FlashLinePayEvidenceTest.payment();p.setId(id);p.setOrderId(id);p.setRiderId(10L);p.setCredentialId(45L);p.setVersion(0L);p.setStatus("WAITING_AUTH");p.setNextReconcileAt(new Date(0));paidRows.put(id,p);
+         FlashDeliveryLineRefund r=new FlashDeliveryLineRefund();r.setId(id);r.setPaymentId(id);r.setAmount(66);r.setVersion(0L);r.setStatus("UNKNOWN");r.setNextReconcileAt(new Date(0));refundRows.put(id,r);
+     }
+     // Persistence doubles keep facts written by the real services, including their next-probe scheduling.
+     when(payments.selectById(anyLong())).thenAnswer(i->paidRows.get(i.getArgument(0)));when(payments.selectForUpdate(anyLong())).thenAnswer(i->paidRows.get(i.getArgument(0)));
+     when(refunds.selectById(anyLong())).thenAnswer(i->refundRows.get(i.getArgument(0)));when(refunds.selectForUpdate(anyLong())).thenAnswer(i->refundRows.get(i.getArgument(0)));
+     when(orders.selectByIdForUpdate(anyLong())).thenAnswer(i->{FlashDeliveryOrder o=FlashLinePayFactServiceTest.order();o.setId(i.getArgument(0));return o;});
+     when(payments.updateVersioned(any(),anyLong())).thenReturn(1);when(refunds.updateVersioned(any(),anyLong())).thenReturn(1);
+     when(payments.selectDue(any(),anyInt())).thenAnswer(i->paidRows.values().stream().filter(p->!p.getNextReconcileAt().after(i.getArgument(0)))
+             .sorted(Comparator.comparing(FlashDeliveryLinePayment::getNextReconcileAt).thenComparing(FlashDeliveryLinePayment::getId)).limit((Integer)i.getArgument(1)).toList());
+     when(refunds.selectDue(any(),anyInt())).thenAnswer(i->refundRows.values().stream().filter(r->!r.getNextReconcileAt().after(i.getArgument(0)))
+             .sorted(Comparator.comparing(FlashDeliveryLineRefund::getNextReconcileAt).thenComparing(FlashDeliveryLineRefund::getId)).limit((Integer)i.getArgument(1)).toList());
+     InfoRiderLinePay c=new InfoRiderLinePay();c.setId(45L);c.setEnvironment("SANDBOX");c.setChannelId("fake");c.setChannelSecret("fake-secret");when(credentials.getById(45L)).thenReturn(c);
+     when(client.retrieveByOrderId(any(),anyString())).thenReturn(new LinePayResponse(200,"1150","No history",null,null,"{}"));
+     when(client.check(any(),anyString())).thenReturn(new LinePayResponse(200,"0000","Waiting",null,null,"{}"));
+     FlashLinePayFactService facts=new FlashLinePayFactService(orders,payments,refunds,credentials);
+     FlashLinePayService service=new FlashLinePayService(facts,orders,payments,refunds,credentials,client,new LinePayProperties());
+     FlashLinePayRefundService refundService=new FlashLinePayRefundService(facts,payments,refunds,credentials,client,new LinePayProperties());
+     RedissonClient redisson=mock(RedissonClient.class);RLock lock=mock(RLock.class);when(redisson.getLock(anyString())).thenReturn(lock);when(lock.tryLock()).thenReturn(true);when(lock.isHeldByCurrentThread()).thenReturn(true);
+     FlashLinePayReconcileTask task=new FlashLinePayReconcileTask(payments,refunds,service,refundService,redisson);
+     task.reconcile();task.reconcile();
+     assertTrue(paidRows.get(21L).getNextReconcileAt().after(new Date()),"The 21st payment must be probed in the second round");
+     assertTrue(refundRows.get(21L).getNextReconcileAt().after(new Date()),"The 21st refund must also be probed");
+     assertEquals("WAITING_AUTH",paidRows.get(1L).getStatus());assertEquals("UNKNOWN",refundRows.get(1L).getStatus());
+     verify(client,never()).confirm(any(),anyString(),anyInt(),anyString());verify(client,never()).refundFull(any(),anyString());
+ }
+}

+ 40 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRefundServiceTest.java

@@ -0,0 +1,40 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.junit.jupiter.api.Assertions.*;
+class FlashLinePayRefundServiceTest {
+ private final FlashLinePayFactService facts=mock(FlashLinePayFactService.class);
+ private final FlashDeliveryLinePaymentMapper payments=mock(FlashDeliveryLinePaymentMapper.class);
+ private final FlashDeliveryLineRefundMapper refunds=mock(FlashDeliveryLineRefundMapper.class);
+ private final IInfoRiderLinePayService credentials=mock(IInfoRiderLinePayService.class);
+ private final LinePayClient client=mock(LinePayClient.class);
+ private final FlashLinePayRefundService service=new FlashLinePayRefundService(facts,payments,refunds,credentials,client,new LinePayProperties());
+ private FlashDeliveryLineRefund refund;private MockedStatic<MessageUtils> messages;
+ @BeforeEach void setup(){
+  messages=mockStatic(MessageUtils.class);messages.when(()->MessageUtils.message(anyString())).thenAnswer(i->i.getArgument(0));
+  FlashDeliveryLinePayment p=FlashLinePayEvidenceTest.payment();p.setId(22L);p.setCredentialId(45L);p.setCapturedAmount(66);p.setStatus("PAID");when(payments.selectById(22L)).thenReturn(p);
+  refund=new FlashDeliveryLineRefund();refund.setId(77L);refund.setPaymentId(22L);refund.setAmount(66);refund.setVersion(0L);refund.setStatus("CREATED");when(refunds.selectById(77L)).thenReturn(refund);
+  InfoRiderLinePay c=new InfoRiderLinePay();c.setId(45L);c.setEnvironment("SANDBOX");c.setChannelId("fake-old");c.setChannelSecret("fake-old-secret");c.setIsEnabled(0);when(credentials.getById(45L)).thenReturn(c);
+  when(facts.claimRefund(77L)).thenAnswer(i->{if(!"CREATED".equals(refund.getStatus()))return null;refund.setStatus("PROCESSING");refund.setVersion(1L);return refund;});
+  doAnswer(i->{refund.setStatus(i.getArgument(3));return null;}).when(facts).refundStatus(eq(77L),anyLong(),anyString(),anyString());
+  doAnswer(i->{refund.setStatus("REFUNDED");return null;}).when(facts).applyRefundedFact(eq(77L),anyString(),any());
+ }
+ @AfterEach void close(){messages.close();}
+ @Test void refundTimeoutOnlyRetrieves() throws Exception {when(client.retrieveByOrderId(any(),any())).thenReturn(FlashLinePayEvidenceTest.response("["+FlashLinePayEvidenceTest.transaction()+"]"));when(client.refundFull(any(),any())).thenThrow(new IllegalStateException("timeout"));service.reconcile(77L,"TEST");assertEquals("UNKNOWN",refund.getStatus());service.reconcile(77L,"TEST");assertEquals("UNKNOWN",refund.getStatus());verify(client,times(1)).refundFull(any(),any());}
+ @Test void rotatedCredentialRefundUsesOriginalVersion() throws Exception {when(client.retrieveByOrderId(any(),any())).thenReturn(FlashLinePayEvidenceTest.response("["+FlashLinePayEvidenceTest.transaction()+"]"));when(client.refundFull(any(),any())).thenReturn(new LinePayResponse(200,"0000","OK",null,com.alibaba.fastjson2.JSON.parseObject("{\"refundTransactionId\":\"9876543210123456789\"}"),"{}"));service.reconcile(77L,"TEST");assertEquals("REFUNDED",refund.getStatus());org.mockito.ArgumentCaptor<LinePayCredential> c=org.mockito.ArgumentCaptor.forClass(LinePayCredential.class);verify(client).refundFull(c.capture(),eq("2026081200000000001"));assertEquals("fake-old-secret",c.getValue().channelSecret());verify(credentials,never()).getCurrent(any());}
+ @Test void alreadyFullyRefundedIsNotRefundedAgain() throws Exception {com.alibaba.fastjson2.JSONObject tx=FlashLinePayEvidenceTest.transaction();tx.put("refundList",com.alibaba.fastjson2.JSON.parseArray("[{\"refundAmount\":-66,\"refundTransactionId\":\"987\"}]"));when(client.retrieveByOrderId(any(),any())).thenReturn(FlashLinePayEvidenceTest.response("["+tx+"]"));service.reconcile(77L,"TEST");assertEquals("REFUNDED",refund.getStatus());verify(client,never()).refundFull(any(),any());}
+ @Test void expiredLeaseRequiresReadOnlyRecovery() throws Exception {refund.setStatus("PROCESSING");refund.setLeaseUntil(new java.util.Date(0));when(client.retrieveByOrderId(any(),any())).thenReturn(new LinePayResponse(200,"1150","No history",null,null,"{}"));service.reconcile(77L,"TEST");assertEquals("UNKNOWN",refund.getStatus());verify(client,never()).refundFull(any(),any());}
+ @Test void concurrentRefundProofSurvivesAnExpiredReadOnlyProbe() throws Exception {
+     refund.setStatus("UNKNOWN");refund.setReconcileDeadline(new java.util.Date(0));
+     when(client.retrieveByOrderId(any(),any())).thenAnswer(i->{refund.setStatus("REFUNDED");refund.setVersion(2L);return new LinePayResponse(200,"1150","No history",null,null,"{}");});
+     service.reconcile(77L,"TEST");assertEquals("REFUNDED",refund.getStatus());
+ }
+}

+ 52 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayServiceTest.java

@@ -0,0 +1,52 @@
+package com.ruoyi.app.flashdelivery.payment;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+class FlashLinePayServiceTest {
+ private final FlashLinePayFactService facts=mock(FlashLinePayFactService.class);
+ private final FlashDeliveryOrderMapper orders=mock(FlashDeliveryOrderMapper.class);
+ private final FlashDeliveryLinePaymentMapper payments=mock(FlashDeliveryLinePaymentMapper.class);
+ private final FlashDeliveryLineRefundMapper refunds=mock(FlashDeliveryLineRefundMapper.class);
+ private final IInfoRiderLinePayService credentials=mock(IInfoRiderLinePayService.class);
+ private final LinePayClient client=mock(LinePayClient.class);
+ private final LinePayProperties properties=new LinePayProperties();
+ private FlashLinePayService service;private FlashDeliveryLinePayment row;private MockedStatic<MessageUtils> messages;
+ @BeforeEach void setup(){
+  messages=mockStatic(MessageUtils.class);messages.when(()->MessageUtils.message(anyString())).thenAnswer(i->i.getArgument(0));
+  properties.setFlashConfirmUrl("https://api.example.com/pay/line/flash/confirm");properties.setFlashCancelUrl("https://api.example.com/pay/line/flash/cancel");
+  service=new FlashLinePayService(facts,orders,payments,refunds,credentials,client,properties);
+  row=FlashLinePayEvidenceTest.payment();row.setId(22L);row.setOrderId(1L);row.setRiderId(10L);row.setCredentialId(45L);row.setStatus("CREATED");row.setVersion(0L);
+  when(facts.createIntent(8L,1L)).thenReturn(row);when(payments.selectLatest(1L)).thenReturn(row);when(payments.selectById(22L)).thenReturn(row);when(orders.selectById(1L)).thenReturn(FlashLinePayFactServiceTest.order());
+  InfoRiderLinePay c=new InfoRiderLinePay();c.setId(45L);c.setEnvironment("SANDBOX");c.setChannelId("fake");c.setChannelSecret("fake-secret");when(credentials.getById(45L)).thenReturn(c);when(credentials.getEnabledCurrent(10L)).thenReturn(c);
+  when(facts.claim(eq(22L),anyString(),anyString())).thenAnswer(i->{if(!row.getStatus().equals(i.getArgument(1)))return null;row.setStatus(i.getArgument(2));row.setVersion(row.getVersion()+1);return row;});
+  doAnswer(i->{row.setStatus(i.getArgument(3));return null;}).when(facts).setStatus(eq(22L),anyLong(),anyString(),anyString(),anyBoolean());
+ }
+ @AfterEach void close(){messages.close();}
+ @Test void requestTimeoutDoesNotCreateSecondAttempt() throws Exception {when(client.request(any(),any(),anyString(),anyString())).thenThrow(new IllegalStateException("timeout"));assertEquals("REQUEST_UNKNOWN",service.create(8L,1L).getStatus());assertEquals("REQUEST_UNKNOWN",service.create(8L,1L).getStatus());verify(client,times(1)).request(any(),any(),anyString(),anyString());}
+ @Test void duplicateCreateReturnsExistingPaymentUrl() throws Exception {row.setStatus("WAITING_AUTH");row.setPaymentUrl("https://pay.example.com/web");assertEquals("https://pay.example.com/web",service.create(8L,1L).getPaymentUrl());verifyNoInteractions(client);}
+ @Test void confirmTimeoutOnlyRetrievesAfterwards() throws Exception {row.setStatus("WAITING_AUTH");when(client.retrieveByOrderId(any(),anyString())).thenReturn(new LinePayResponse(200,"1150","No history",null,null,"{}"));when(client.check(any(),anyString())).thenReturn(new LinePayResponse(200,"0110","Ready",null,null,"{}"));when(client.confirm(any(),anyString(),anyInt(),anyString())).thenThrow(new IllegalStateException("timeout"));service.reconcile(22L,"TEST");assertEquals("CONFIRM_UNKNOWN",row.getStatus());service.reconcile(22L,"TEST");assertEquals("CONFIRM_UNKNOWN",row.getStatus());verify(client,times(1)).confirm(any(),anyString(),eq(66),eq("TWD"));}
+ @Test void riderQueryDoesNotExposePaymentUrl(){row.setStatus("PAID");row.setPaymentUrl("https://pay.example/web");assertNull(service.query(10L,1L).getPaymentUrl());}
+ @Test void forgedReturnCannotLocateBoundAttempt(){when(payments.selectByLineOrderId("FLP-test")).thenReturn(row);assertNull(service.findByLineOrderId("FLP-test","wrong"));assertSame(row,service.findByLineOrderId("FLP-test","2026081200000000001"));}
+ @Test void disabledCredentialTerminatesOnlyAnUnsentIntent() throws Exception {
+     when(credentials.getEnabledCurrent(10L)).thenReturn(null);
+     assertEquals("CANCELLED",service.create(8L,1L).getStatus());verify(client,never()).request(any(),any(),anyString(),anyString());
+ }
+ @Test void failedReadDoesNotErasePreviouslyVerifiedPayment() throws Exception {
+     row.setStatus("PAID");row.setPaidAt(new java.util.Date());row.setReconcileDeadline(new java.util.Date(0));
+     when(client.retrieveByOrderId(any(),anyString())).thenThrow(new IllegalStateException("timeout"));
+     service.reconcile(22L,"QUERY");assertEquals("PAID",row.getStatus());
+ }
+ @Test void environmentAndGatewayMismatchPreventsNewPayment() throws Exception {
+     properties.setBaseUrl("https://api-pay.line.me");
+     assertThrows(com.ruoyi.common.exception.ServiceException.class,()->service.create(8L,1L));
+     verify(client,never()).request(any(),any(),anyString(),anyString());
+ }
+}

+ 57 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -1129,24 +1129,76 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void adminCancelSuccessNotifiesAssignedRider() {
         Fixture fixture = new Fixture();
-        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        FlashDeliveryOrder pickedUp = order(10L, "ACCEPTED");
         pickedUp.setRiderId(9L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
         // 平台异常取消先写介入审计,再走条件取消。
         when(fixture.orderMapper.update(any(), any())).thenReturn(1);
-        when(fixture.orderMapper.cancel(eq(10L), eq("PICKED_UP"), eq("ADMIN"), eq(1L),
+        when(fixture.orderMapper.cancel(eq(10L), eq("ACCEPTED"), eq("ADMIN"), eq(1L),
                 anyString(), any(Date.class))).thenReturn(1);
         FlashDeliveryReasonRequest request = reason("异常收口");
         request.setProblemStopId(101L);
         request.setHandlingResult("平台介入收口");
-        // 已取货订单必须记录物品去向和交接凭证。
-        request.setPickedGoodsDisposition("已取物品交回寄件人");
-        request.setHandoffImageUrls(List.of("https://example.com/handoff.jpg"));
+        // 新规则只允许取件前取消,无需虚构物品交接凭证。
 
         fixture.service.adminCancel(1L, 10L, request);
 
         verify(fixture.notificationService).notifyCancelled(pickedUp);
     }
+    @Test
+    void adminCannotCancelPartlyPickedOrderEvenWhenMainStatusIsAccepted() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "ACCEPTED");order.setPickedUpStopCount(0);order.setPickupStopCount(1);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        var pickup = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();pickup.setId(101L);pickup.setStopType("PICKUP");pickup.setStatus("PICKED_UP");
+        var delivery = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();delivery.setId(102L);delivery.setStopType("DELIVERY");
+        when(fixture.stopMapper.selectList(any())).thenReturn(List.of(pickup,delivery));
+        when(fixture.orderMapper.update(any(), any())).thenReturn(1);
+        when(fixture.orderMapper.cancel(any(),any(),any(),any(),any(),any())).thenReturn(1);
+        FlashDeliveryReasonRequest request=reason("cancel");request.setProblemStopId(101L);request.setHandlingResult("support");request.setPickedGoodsDisposition("return");request.setHandoffImageUrls(List.of("https://example.com/proof.jpg"));
+        assertThrows(ServiceException.class,()->fixture.service.adminCancel(1L,10L,request));
+        verify(fixture.orderMapper,never()).cancel(any(),any(),any(),any(),any(),any());
+    }
+    @Test
+    void linePayCannotBeMarkedPaidByRider() {
+        Fixture fixture=new Fixture();rider(fixture,8L);FlashDeliveryOrder order=order(10L,"DELIVERED");order.setRiderId(8L);order.setPayType("3");order.setPaymentStatus(0);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        assertThrows(ServiceException.class,()->fixture.service.confirmPayment(8L,10L));
+        verify(fixture.orderMapper,never()).update(any(),any());
+    }
+    @Test
+    void missingStationFactsMustNotDefaultToCancellable() {
+        Fixture fixture=new Fixture();FlashDeliveryOrder order=order(10L,"ACCEPTED");
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);when(fixture.stopMapper.selectList(any())).thenReturn(List.of());
+        assertEquals(Boolean.FALSE,fixture.service.adminDetail(10L).getCanAdminCancel());
+    }
+    @Test
+    void beforePickupLinePayCancellationWritesRefundIntentOnlyAfterSuccessfulCancel() {
+        Fixture fixture=new Fixture();FlashDeliveryOrder order=order(10L,"ACCEPTED");order.setPayType("3");
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);when(fixture.orderMapper.cancel(any(),any(),any(),any(),any(),any())).thenReturn(1);
+        var facts=mock(com.ruoyi.app.flashdelivery.payment.FlashLinePayFactService.class);org.springframework.test.util.ReflectionTestUtils.setField(fixture.service,"flashLinePayFactService",facts);
+        fixture.service.userCancel(7L,10L,reason("cancel"));verify(facts).ensureCancellationRefundIntent(10L);
+        org.mockito.Mockito.reset(facts);when(fixture.orderMapper.cancel(any(),any(),any(),any(),any(),any())).thenReturn(0);
+        assertThrows(ServiceException.class,()->fixture.service.userCancel(7L,10L,reason("cancel")));verifyNoInteractions(facts);
+    }
+    @Test
+    void unpaidLinePayCannotPickupEvenWithValidProof() {
+        Fixture fixture=new Fixture();rider(fixture,8L);FlashDeliveryOrder order=order(10L,"ACCEPTED");order.setRiderId(8L);order.setPayType("3");
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);when(fixture.orderMapper.updateFulfilment(any())).thenReturn(1);
+        var facts=mock(com.ruoyi.app.flashdelivery.payment.FlashLinePayFactService.class);
+        org.springframework.test.util.ReflectionTestUtils.setField(fixture.service,"flashLinePayFactService",facts);
+        doThrow(new ServiceException("unpaid")).when(facts).assertPickupPaid(order);
+        FlashDeliveryProofRequest request=new FlashDeliveryProofRequest();request.setImageUrls(List.of("https://example.com/proof.jpg"));
+        assertThrows(ServiceException.class,()->fixture.service.pickup(8L,10L,request));
+        verify(fixture.stopMapper,never()).updateById(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderStop.class));
+    }
+    @Test
+    void riderCannotBypassPaymentReadinessByCallingAcceptDirectly() {
+        Fixture fixture=new Fixture();rider(fixture,8L);FlashDeliveryOrder order=order(10L,"WAITING_ACCEPTANCE");order.setPayType("3");order.setVehicleType(1);order.setIsDisplay(true);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);when(fixture.orderMapper.accept(any(),any(),any())).thenReturn(1);
+        doThrow(new ServiceException("unavailable")).when(fixture.paymentMethodGateService).assertUsable("3",com.ruoyi.system.service.PaymentMethodGateService.GateScope.RIDER_FLASH,null,8L);
+        assertThrows(ServiceException.class,()->fixture.service.accept(8L,10L));verify(fixture.orderMapper,never()).accept(any(),any(),any());
+    }
 
     @Test
     void cancelFailureDoesNotTriggerNotification() {

+ 3 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java

@@ -179,7 +179,7 @@ class FlashDeliveryMultiPickupTest {
         assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 12L, target(0, "b", "reason", true)));
     }
 
-    @Test void platformRequiresCustodyProofBeforeCancellingPartiallyPickedOrder() {
+    @Test void platformCannotCancelPartiallyPickedOrderEvenWithCustodyProof() {
         Fixture f = new Fixture(); f.service.pickup(8L, 10L, proof(11L));
         FlashDeliveryReasonRequest request = new FlashDeliveryReasonRequest();
         request.setReason("missing goods"); request.setProblemStopId(12L); request.setHandlingResult("closed after return");
@@ -189,8 +189,8 @@ class FlashDeliveryMultiPickupTest {
         request.setHandoffImageUrls(List.of("/profile/handoff.jpg"));
         when(f.orders.update(isNull(), any())).thenReturn(1);
         when(f.orders.cancel(any(), any(), any(), any(), any(), any())).thenReturn(1);
-        f.service.adminCancel(1L, 10L, request);
-        verify(f.orders).cancel(eq(10L), eq("ACCEPTED"), eq("ADMIN"), eq(1L), eq("missing goods"), any());
+        assertThrows(ServiceException.class, () -> f.service.adminCancel(1L, 10L, request));
+        verify(f.orders, never()).cancel(any(), any(), any(), any(), any(), any());
     }
 
     @Test void platformCompleteCannotBypassPendingPickupsEvenIfMainStateIsCorrupt() {

+ 5 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachineTest.java

@@ -20,12 +20,14 @@ class FlashDeliveryStateMachineTest {
     }
 
     @Test
-    void userAndAdminCancellationRulesAreDifferent() {
+    void neitherUserNorAdminCanCancelAfterPickup() {
         assertTrue(FlashDeliveryStateMachine.canUserCancel(WAITING_ACCEPTANCE));
         assertTrue(FlashDeliveryStateMachine.canUserCancel(ACCEPTED));
         assertFalse(FlashDeliveryStateMachine.canUserCancel(PICKED_UP));
-        assertTrue(FlashDeliveryStateMachine.canAdminCancel(PICKED_UP));
-        assertTrue(FlashDeliveryStateMachine.canAdminCancel(DELIVERED));
+        assertFalse(FlashDeliveryStateMachine.canAdminCancel(PICKED_UP));
+        assertFalse(FlashDeliveryStateMachine.canAdminCancel(DELIVERED));
+        assertFalse(FlashDeliveryStateMachine.canAdminCancel(null));
+        assertFalse(FlashDeliveryStateMachine.canAdminCancel("UNKNOWN"));
         assertFalse(FlashDeliveryStateMachine.canAdminCancel(COMPLETED));
     }
 }

+ 32 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/RiderLinePayControllerTest.java

@@ -0,0 +1,32 @@
+package com.ruoyi.app.pay;
+import com.ruoyi.app.utils.linepay.*;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.dto.RiderLinePayCredentialDto;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.junit.jupiter.api.Assertions.*;
+class RiderLinePayControllerTest {
+ private final IInfoRiderLinePayService service=mock(IInfoRiderLinePayService.class);
+ private final LinePayClient client=mock(LinePayClient.class);
+ private final LinePayProperties properties=new LinePayProperties();
+ private final RiderLinePayController controller=new RiderLinePayController(service,client,properties);
+ private MockedStatic<MessageUtils> messages;
+ @BeforeEach void setUp(){messages=mockStatic(MessageUtils.class);messages.when(()->MessageUtils.message(anyString())).thenAnswer(i->i.getArgument(0));}
+ @AfterEach void close(){messages.close();}
+ @Test void nonRiderRejectedBeforeProbe() throws Exception {doThrow(new ServiceException("invalid")).when(service).assertRider(10L);assertThrows(ServiceException.class,()->controller.saveCredentials(dto()));verifyNoInteractions(client);}
+ @Test void invalidProbePreservesCurrent() throws Exception {when(client.retrieveByOrderId(any(),any())).thenReturn(new LinePayResponse(401,"1104","invalid",null,null,"{}"));assertEquals(500,controller.saveCredentials(dto()).get(AjaxResult.CODE_TAG));verify(service,never()).saveVerifiedCredential(any(),any(),any(),any());}
+ @Test void malformedProbeReturnsUnknownWithoutSaving() throws Exception {when(client.retrieveByOrderId(any(),any())).thenReturn(new LinePayResponse(200,"0000","OK",null,null,"not-json"));assertEquals(500,controller.saveCredentials(dto()).get(AjaxResult.CODE_TAG));verify(service,never()).saveVerifiedCredential(any(),any(),any(),any());}
+ @Test void noHistoryProbeSavesVerifiedVersion() throws Exception {when(client.retrieveByOrderId(any(),any())).thenReturn(new LinePayResponse(200,"1150","No history",null,null,"{}"));assertEquals(200,controller.saveCredentials(dto()).get(AjaxResult.CODE_TAG));verify(service).saveVerifiedCredential(any(),eq("SANDBOX"),eq("1150"),isNull());}
+ @Test void environmentAndGatewayMismatchRejectsBeforeProbingOrSaving() throws Exception {
+     properties.setBaseUrl("https://api-pay.line.me");
+     when(client.retrieveByOrderId(any(),any())).thenReturn(new LinePayResponse(200,"1150","No history",null,null,"{}"));
+     assertEquals(500,controller.saveCredentials(dto()).get(AjaxResult.CODE_TAG));
+     verifyNoInteractions(client);verify(service,never()).saveVerifiedCredential(any(),any(),any(),any());
+ }
+ private static RiderLinePayCredentialDto dto(){RiderLinePayCredentialDto dto=new RiderLinePayCredentialDto();dto.setRiderId(10L);dto.setChannelId("fake");dto.setChannelSecret("fake-secret");return dto;}
+}

+ 30 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/task/FlashLinePayReconcileTaskTest.java

@@ -0,0 +1,30 @@
+package com.ruoyi.app.task;
+import com.ruoyi.app.flashdelivery.payment.*;
+import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.domain.flash.*;
+import org.redisson.api.*;
+import org.junit.jupiter.api.*;
+import java.util.List;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+class FlashLinePayReconcileTaskTest {
+ private final FlashDeliveryLinePaymentMapper payments=mock(FlashDeliveryLinePaymentMapper.class);
+ private final FlashDeliveryLineRefundMapper refunds=mock(FlashDeliveryLineRefundMapper.class);
+ private final FlashLinePayService service=mock(FlashLinePayService.class);
+ private final FlashLinePayRefundService refundService=mock(FlashLinePayRefundService.class);
+ private final RedissonClient redisson=mock(RedissonClient.class);
+ private final RLock lock=mock(RLock.class);
+ private final FlashLinePayReconcileTask task=new FlashLinePayReconcileTask(payments,refunds,service,refundService,redisson);
+ @BeforeEach void setup(){when(redisson.getLock("lock:flash-line-pay:reconcile")).thenReturn(lock);when(lock.tryLock()).thenReturn(true);when(lock.isHeldByCurrentThread()).thenReturn(true);}
+ @Test void oneFailedPaymentDoesNotBlockOthersOrRefunds(){
+     FlashDeliveryLinePayment first=new FlashDeliveryLinePayment();first.setId(1L);FlashDeliveryLinePayment next=new FlashDeliveryLinePayment();next.setId(2L);FlashDeliveryLineRefund refund=new FlashDeliveryLineRefund();refund.setId(3L);
+     when(payments.selectDue(any(),anyInt())).thenReturn(List.of(first,next));when(refunds.selectDue(any(),anyInt())).thenReturn(List.of(refund));
+     doThrow(new IllegalStateException("simulated")).when(service).reconcile(1L,"TASK");task.reconcile();
+     verify(service).reconcile(2L,"TASK");verify(refundService).reconcile(3L,"TASK");verify(lock).unlock();
+ }
+ @Test void overlappingTaskDoesNotScan(){when(lock.tryLock()).thenReturn(false);task.reconcile();verifyNoInteractions(payments,refunds,service,refundService);verify(lock,never()).unlock();}
+ @Test void latePaymentIsReconciledBeforeScanningRefundIntents(){
+     FlashDeliveryLinePayment p=new FlashDeliveryLinePayment();p.setId(1L);when(payments.selectDue(any(),anyInt())).thenReturn(List.of(p));
+     task.reconcile();var sequence=inOrder(service,refunds);sequence.verify(service).reconcile(1L,"TASK");sequence.verify(refunds).selectDue(any(),anyInt());
+ }
+}

+ 9 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java

@@ -164,6 +164,15 @@ class LinePayClientTest {
         return new LinePayCredential(5L, "1234567890", "test-secret");
     }
 
+    @Test void flashRequestUsesDedicatedRedirectsWithoutChangingFoodRequest() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\",\"info\":{\"transactionId\":\"1234567890123456789\"}}";
+        client.request(credential(), new LinePayRequest("FLP-1", "1", "Flash", 66, "TWD"),
+                "https://api.example.com/pay/line/flash/confirm?orderId=FLP-1", "https://api.example.com/pay/line/flash/cancel?orderId=FLP-1");
+        assertEquals("https://api.example.com/pay/line/flash/confirm?orderId=FLP-1", JSON.parseObject(transport.body).getJSONObject("redirectUrls").getString("confirmUrl"));
+        client.request(credential(), new LinePayRequest("FOOD-1", "DD-1", "Food", 66, "TWD"));
+        assertEquals("https://api.example.com/pay/line/confirm", JSON.parseObject(transport.body).getJSONObject("redirectUrls").getString("confirmUrl"));
+    }
+
     private static final class RecordingTransport implements LinePayHttpTransport {
         String method;
         String url;

+ 22 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoRiderLinePay.java

@@ -0,0 +1,22 @@
+package com.ruoyi.system.domain;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.ToString;
+import java.util.Date;
+/** 骑手独立的 LINE Pay 不可变凭证版本;历史交易始终引用原版本。 */
+@Data @TableName("info_rider_line_pay")
+public class InfoRiderLinePay {
+ @TableId(type=IdType.AUTO) private Long id;
+ private Long riderId;
+ private Integer credentialVersion;
+ private String channelId;
+ @ToString.Exclude private String channelSecret;
+ private String environment;
+ private String credentialStatus;
+ private Integer isEnabled;
+ private Long currentRiderId;
+ private String verifyReturnCode;
+ private Date verifiedTime;
+ private Date createTime;
+ private Date updateTime;
+}

+ 9 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/RiderLinePayCredentialDto.java

@@ -0,0 +1,9 @@
+package com.ruoyi.system.domain.dto;
+import lombok.Data;
+import lombok.ToString;
+/** 平台配置请求;环境由后端部署决定,不允许客户端更换网关。 */
+@Data public class RiderLinePayCredentialDto {
+ private Long riderId;
+ private String channelId;
+ @ToString.Exclude private String channelSecret;
+}

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/RiderLinePayToggleDto.java

@@ -0,0 +1,3 @@
+package com.ruoyi.system.domain.dto;
+import lombok.Data;
+@Data public class RiderLinePayToggleDto { private Long riderId; private Boolean enabled; }

+ 27 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryLinePayment.java

@@ -0,0 +1,27 @@
+package com.ruoyi.system.domain.flash;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import java.util.Date;
+/** 闪送独立资金记录,不与外卖订单或门店凭证混用。 */
+@Data @TableName("flash_delivery_line_payment")
+public class FlashDeliveryLinePayment {
+ @TableId(type=IdType.AUTO) private Long id;
+ private Long orderId;
+ private Long riderId;
+ private Long credentialId;
+ private String lineOrderId;
+ private String transactionId;
+ private Integer amount;
+ private Integer capturedAmount;
+ private String currency;
+ private String paymentUrl;
+ private String status;
+ private Long activeOrderId;
+ private Long version;
+ private Date leaseUntil;
+ private Date nextReconcileAt;
+ private Date reconcileDeadline;
+ private Date paidAt;
+ private Date createTime;
+ private Date updateTime;
+}

+ 20 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryLineRefund.java

@@ -0,0 +1,20 @@
+package com.ruoyi.system.domain.flash;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import java.util.Date;
+/** 闪送独立资金记录,不与外卖订单或门店凭证混用。 */
+@Data @TableName("flash_delivery_line_refund")
+public class FlashDeliveryLineRefund {
+ @TableId(type=IdType.AUTO) private Long id;
+ private Long paymentId;
+ private Integer amount;
+ private String status;
+ private String refundTransactionId;
+ private Long version;
+ private Date leaseUntil;
+ private Date nextReconcileAt;
+ private Date reconcileDeadline;
+ private Date refundedAt;
+ private Date createTime;
+ private Date updateTime;
+}

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -33,6 +33,9 @@ public class FlashDeliveryOrder {
     private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
+    /** 平台只读操作资格,由站点事实计算,不对应数据库列。 */
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Boolean canAdminCancel;
     /** 是否已向骑手开放展示;立即单创建即开放,预约单到达时间窗后开放。 */
     private Boolean isDisplay;
     /** 用户申报的包裹类别和重量范围。 */

+ 7 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/InfoRiderLinePayVo.java

@@ -0,0 +1,7 @@
+package com.ruoyi.system.domain.vo;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+/** 列表 SQL 不查询 Secret,详情由平台独立权限保护。 */
+@Data @EqualsAndHashCode(callSuper=true)
+public class InfoRiderLinePayVo extends InfoRiderLinePay { private String userName; private String phone; private Integer hasCredential; }

+ 14 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoRiderLinePayMapper.java

@@ -0,0 +1,14 @@
+package com.ruoyi.system.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.vo.InfoRiderLinePayVo;
+import org.apache.ibatis.annotations.Param;
+import java.util.*;
+public interface InfoRiderLinePayMapper extends BaseMapper<InfoRiderLinePay> {
+ Long lockRider(@Param("riderId") Long riderId);
+ InfoRiderLinePay selectCurrentByRiderId(@Param("riderId") Long riderId);
+ Integer selectMaxVersionByRiderId(@Param("riderId") Long riderId);
+ int clearCurrent(@Param("id") Long id,@Param("riderId") Long riderId,@Param("now") Date now);
+ List<InfoRiderLinePayVo> selectRiderList(@Param("userName") String userName);
+ InfoRiderLinePayVo selectRiderDetail(@Param("riderId") Long riderId);
+}

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryLinePaymentMapper.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.mapper.flash;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.flash.FlashDeliveryLinePayment;
+import org.apache.ibatis.annotations.Param;
+import java.util.*;
+public interface FlashDeliveryLinePaymentMapper extends BaseMapper<FlashDeliveryLinePayment> {
+ FlashDeliveryLinePayment selectForUpdate(@Param("id") Long id);
+ FlashDeliveryLinePayment selectLatest(@Param("orderId") Long orderId);
+ FlashDeliveryLinePayment selectLatestForUpdate(@Param("orderId") Long orderId);
+ FlashDeliveryLinePayment selectByLineOrderId(@Param("lineOrderId") String lineOrderId);
+ int updateVersioned(@Param("row") FlashDeliveryLinePayment row,@Param("expectedVersion") Long expectedVersion);
+ List<FlashDeliveryLinePayment> selectDue(@Param("now") Date now,@Param("limit") int limit);
+}

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryLineRefundMapper.java

@@ -0,0 +1,12 @@
+package com.ruoyi.system.mapper.flash;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.flash.FlashDeliveryLineRefund;
+import org.apache.ibatis.annotations.Param;
+import java.util.*;
+public interface FlashDeliveryLineRefundMapper extends BaseMapper<FlashDeliveryLineRefund> {
+ FlashDeliveryLineRefund selectForUpdate(@Param("id") Long id);
+ FlashDeliveryLineRefund selectByPaymentId(@Param("paymentId") Long paymentId);
+ FlashDeliveryLineRefund selectByPaymentIdForUpdate(@Param("paymentId") Long paymentId);
+ int updateVersioned(@Param("row") FlashDeliveryLineRefund row,@Param("expectedVersion") Long expectedVersion);
+ List<FlashDeliveryLineRefund> selectDue(@Param("now") Date now,@Param("limit") int limit);
+}

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java

@@ -48,7 +48,7 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
                            @Param("nextStatus") String nextStatus,
                            @Param("timeColumn") String timeColumn, @Param("now") Date now);
 
-    /** 记录取消操作人和原因,并仅从调用方已校验的当前状态转为已取消。 */
+    /** 仅实际取件前可取消,记录审计;条件更新再次防止部分取件或旧入口越过门禁。 */
     int cancel(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
                @Param("operatorType") String operatorType, @Param("operatorId") Long operatorId,
                @Param("reason") String reason, @Param("now") Date now);

+ 15 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoRiderLinePayService.java

@@ -0,0 +1,15 @@
+package com.ruoyi.system.service;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.dto.RiderLinePayCredentialDto;
+import com.ruoyi.system.domain.vo.InfoRiderLinePayVo;
+import java.util.List;
+public interface IInfoRiderLinePayService {
+ void assertRider(Long riderId);
+ InfoRiderLinePay getCurrent(Long riderId);
+ InfoRiderLinePay getEnabledCurrent(Long riderId);
+ InfoRiderLinePay getById(Long id);
+ List<InfoRiderLinePayVo> selectRiderList(String name);
+ InfoRiderLinePayVo selectRiderDetail(Long id);
+ InfoRiderLinePay saveVerifiedCredential(RiderLinePayCredentialDto dto,String environment,String code,String message);
+ int setCurrentEnabled(Long riderId,boolean enabled);
+}

+ 9 - 3
ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java

@@ -102,6 +102,8 @@ public class PaymentMethodGateService {
     private final PosStoreOmgMapper omgMapper;
     private final PosStoreLinePayMapper linePayMapper;
     private final PosStoreMapper storeMapper;
+    @org.springframework.beans.factory.annotation.Autowired
+    private IInfoRiderLinePayService riderLinePayService;
 
     public PaymentMethodGateService(PaymentMethodConfigMapper configMapper,
                                     InfoUserMapper userMapper,
@@ -158,8 +160,8 @@ public class PaymentMethodGateService {
 
     /**
      * 列出某维度对收款方当前可用的支付方式(结算页可选项、骑手端可选集共用)。
-     * 返回平台开关 ∩ 收款方选择的全部项并附就绪度;RIDER_FLASH 维度仅线下转账在骑手身份
-     * 已知时按启用卡判定就绪度,其余及骑手未定(用户下单)恒 true。
+     * 返回平台开关 ∩ 收款方选择并附就绪度;已知骑手还须具备对应收款配置。
+     * 骑手未定(用户下单)仅服从平台开关,接单接口再校验具体收款人。
      */
     public List<PayMethodOption> listAvailable(GateScope scope, Long storeId, Long payeeUserId) {
         return listAvailable(scope, storeId, payeeUserId, null);
@@ -291,11 +293,15 @@ public class PaymentMethodGateService {
 
     /**
      * 就绪度判定:MERCHANT 维度线上渠道看门店级凭证、线下转账按普通商家或摊位归属看启用卡;
-     * RIDER_FLASH 维度仅线下转账在骑手身份已知时要求启用卡。
+     * RIDER_FLASH 维度线上收款要求启用的骑手凭证,线下转账要求启用卡。
      */
     private boolean isReady(String group, GateScope scope, Long storeId, Long payeeUserId) {
         if (group.equals(GROUP_COD) || group.equals(GROUP_CASH)) return true;
         if (scope == GateScope.RIDER_FLASH) {
+            // 待抢列表和接单使用同一闸门,不让无 LINE Pay 收款资格的骑手抢线上单。
+            if (GROUP_LINE_PAY.equals(group) && payeeUserId != null) {
+                return riderLinePayService.getEnabledCurrent(payeeUserId) != null;
+            }
             // 线下转账:骑手身份已知(抢单池/骑手端可选集)要求启用卡;用户下单骑手未定(null)放行
             return !GROUP_OFFLINE.equals(group) || payeeUserId == null || bankCardMapper.selectCount(
                     new QueryWrapper<InfoBankCard>().eq("user_id", payeeUserId).eq("is_active", 1)) > 0;

+ 78 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoRiderLinePayServiceImpl.java

@@ -0,0 +1,78 @@
+package com.ruoyi.system.service.impl;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.domain.dto.RiderLinePayCredentialDto;
+import com.ruoyi.system.domain.vo.InfoRiderLinePayVo;
+import com.ruoyi.system.mapper.*;
+import com.ruoyi.system.service.IInfoRiderLinePayService;
+import java.util.List;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Objects;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+@Service
+public class InfoRiderLinePayServiceImpl implements IInfoRiderLinePayService {
+ private final InfoRiderLinePayMapper mapper;
+ private final InfoUserMapper users;
+ private final String environment;
+ public InfoRiderLinePayServiceImpl(InfoRiderLinePayMapper mapper,InfoUserMapper users,
+         @Value("${line-pay.environment:sandbox}") String environment) {
+     this.mapper=mapper; this.users=users; this.environment=environment.toUpperCase(Locale.ROOT);
+ }
+ public void assertRider(Long id) {
+     InfoUser rider=id==null?null:users.selectById(id);
+     if(rider==null || !"2".equals(rider.getUserType()) || !"0".equals(rider.getStatus()) || !"0".equals(rider.getDelFlag()))
+         throw new ServiceException(MessageUtils.message("flash.line.rider.invalid"));
+ }
+ public InfoRiderLinePay getCurrent(Long id){return id==null?null:mapper.selectCurrentByRiderId(id);}
+ public InfoRiderLinePay getEnabledCurrent(Long id){
+     try { assertRider(id); } catch(ServiceException invalid) { return null; }
+     InfoRiderLinePay row=getCurrent(id);
+     return row!=null && "VERIFIED".equals(row.getCredentialStatus()) && Integer.valueOf(1).equals(row.getIsEnabled())
+             && environment.equals(row.getEnvironment())?row:null;
+ }
+ public InfoRiderLinePay getById(Long id){return id==null?null:mapper.selectById(id);}
+ public List<InfoRiderLinePayVo> selectRiderList(String name){return mapper.selectRiderList(name);}
+ public InfoRiderLinePayVo selectRiderDetail(Long id){
+     assertRider(id); InfoRiderLinePayVo row=mapper.selectRiderDetail(id);
+     if(row==null) throw new ServiceException(MessageUtils.message("flash.line.rider.invalid"));
+     return row;
+ }
+ @Transactional(rollbackFor=Exception.class)
+ public InfoRiderLinePay saveVerifiedCredential(RiderLinePayCredentialDto dto,String env,String code,String message){
+     if(dto==null || dto.getChannelId()==null || dto.getChannelId().isBlank() || dto.getChannelId().length()>100
+             || dto.getChannelSecret()==null || dto.getChannelSecret().isBlank() || dto.getChannelSecret().length()>256
+             || !environment.equalsIgnoreCase(env)) throw new ServiceException(MessageUtils.message("line.pay.credential.required"));
+     if(mapper.lockRider(dto.getRiderId())==null) throw new ServiceException(MessageUtils.message("flash.line.rider.invalid"));
+     assertRider(dto.getRiderId());
+     InfoRiderLinePay old=getCurrent(dto.getRiderId()); Date now=new Date();
+     if(old!=null && Objects.equals(old.getChannelId(),dto.getChannelId().trim())
+             && Objects.equals(old.getChannelSecret(),dto.getChannelSecret()) && environment.equals(old.getEnvironment())) {
+         setCurrentEnabled(dto.getRiderId(),true); old.setIsEnabled(1); return old;
+     }
+     // 只清除当前标记,历史密钥不能覆盖,否则旧交易无法查询或退款。
+     if(old!=null && mapper.clearCurrent(old.getId(),dto.getRiderId(),now)!=1)
+         throw new ServiceException(MessageUtils.message("flash.delivery.state.changed"));
+     Integer max=mapper.selectMaxVersionByRiderId(dto.getRiderId());
+     InfoRiderLinePay row=new InfoRiderLinePay(); row.setRiderId(dto.getRiderId());
+     row.setCredentialVersion(max==null?1:Math.addExact(max,1)); row.setChannelId(dto.getChannelId().trim());
+     row.setChannelSecret(dto.getChannelSecret()); row.setEnvironment(environment); row.setCredentialStatus("VERIFIED");
+     row.setIsEnabled(1); row.setCurrentRiderId(dto.getRiderId()); row.setVerifyReturnCode(code);
+     row.setVerifiedTime(now); row.setCreateTime(now); row.setUpdateTime(now);
+     if(mapper.insert(row)!=1) throw new ServiceException(MessageUtils.message("flash.delivery.state.changed"));
+     return row;
+ }
+ @Transactional(rollbackFor=Exception.class)
+ public int setCurrentEnabled(Long id,boolean enabled){
+     if(mapper.lockRider(id)==null) throw new ServiceException(MessageUtils.message("flash.line.rider.invalid"));
+     assertRider(id); InfoRiderLinePay row=getCurrent(id);
+     if(row==null || !"VERIFIED".equals(row.getCredentialStatus()) || !environment.equals(row.getEnvironment()))
+         throw new ServiceException(MessageUtils.message("line.pay.credential.invalid"));
+     return mapper.update(null,new UpdateWrapper<InfoRiderLinePay>().eq("id",row.getId()).eq("current_rider_id",id)
+             .set("is_enabled",enabled?1:0).set("update_time",new Date()));
+ }
+}

+ 15 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryLinePaymentMapper.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.flash.FlashDeliveryLinePaymentMapper">
+ <sql id="columns">id,order_id AS orderId,rider_id AS riderId,credential_id AS credentialId,line_order_id AS lineOrderId,transaction_id AS transactionId,amount AS amount,captured_amount AS capturedAmount,currency AS currency,payment_url AS paymentUrl,status AS status,active_order_id AS activeOrderId,version AS version,lease_until AS leaseUntil,next_reconcile_at AS nextReconcileAt,reconcile_deadline AS reconcileDeadline,paid_at AS paidAt,create_time AS createTime,update_time AS updateTime</sql>
+ <select id="selectForUpdate" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLinePayment">SELECT <include refid="columns"/> FROM flash_delivery_line_payment WHERE id=#{id} FOR UPDATE</select>
+ <select id="selectLatest" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLinePayment">SELECT <include refid="columns"/> FROM flash_delivery_line_payment WHERE order_id=#{orderId} ORDER BY id DESC LIMIT 1</select>
+ <!-- 订单锁后必须当前读,不能使用取得订单锁前建立的旧事务快照。 -->
+ <select id="selectLatestForUpdate" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLinePayment">SELECT <include refid="columns"/> FROM flash_delivery_line_payment WHERE order_id=#{orderId} ORDER BY id DESC LIMIT 1 FOR UPDATE</select>
+ <select id="selectByLineOrderId" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLinePayment">SELECT <include refid="columns"/> FROM flash_delivery_line_payment WHERE line_order_id=#{lineOrderId}</select>
+ <!-- 版本门禁防止旧网关响应或过期任务覆盖新事实。 -->
+ <update id="updateVersioned">UPDATE flash_delivery_line_payment SET transaction_id=#{row.transactionId},captured_amount=#{row.capturedAmount},payment_url=#{row.paymentUrl},status=#{row.status},active_order_id=#{row.activeOrderId},lease_until=#{row.leaseUntil},next_reconcile_at=#{row.nextReconcileAt},reconcile_deadline=#{row.reconcileDeadline},paid_at=#{row.paidAt},update_time=#{row.updateTime},version=version+1 WHERE id=#{row.id} AND version=#{expectedVersion}</update>
+ <select id="selectDue" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLinePayment">SELECT <include refid="columns"/> FROM flash_delivery_line_payment
+ WHERE status IN ('CREATED','REQUESTING','WAITING_AUTH','CONFIRMING','REQUEST_UNKNOWN','CONFIRM_UNKNOWN')
+ AND (next_reconcile_at IS NULL OR next_reconcile_at &lt;= #{now}) AND (lease_until IS NULL OR lease_until &lt;= #{now}) ORDER BY next_reconcile_at, id LIMIT #{limit}</select>
+</mapper>

+ 14 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryLineRefundMapper.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.flash.FlashDeliveryLineRefundMapper">
+ <sql id="columns">id,payment_id AS paymentId,amount AS amount,status AS status,refund_transaction_id AS refundTransactionId,version AS version,lease_until AS leaseUntil,next_reconcile_at AS nextReconcileAt,reconcile_deadline AS reconcileDeadline,refunded_at AS refundedAt,create_time AS createTime,update_time AS updateTime</sql>
+ <select id="selectForUpdate" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLineRefund">SELECT <include refid="columns"/> FROM flash_delivery_line_refund WHERE id=#{id} FOR UPDATE</select>
+ <select id="selectByPaymentId" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLineRefund">SELECT <include refid="columns"/> FROM flash_delivery_line_refund WHERE payment_id=#{paymentId}</select>
+ <!-- 与订单、支付锁保持固定锁序;不存在退款也使用当前读确认。 -->
+ <select id="selectByPaymentIdForUpdate" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLineRefund">SELECT <include refid="columns"/> FROM flash_delivery_line_refund WHERE payment_id=#{paymentId} FOR UPDATE</select>
+ <!-- 版本门禁防止旧网关响应或过期任务覆盖新事实。 -->
+ <update id="updateVersioned">UPDATE flash_delivery_line_refund SET status=#{row.status},refund_transaction_id=#{row.refundTransactionId},lease_until=#{row.leaseUntil},next_reconcile_at=#{row.nextReconcileAt},reconcile_deadline=#{row.reconcileDeadline},refunded_at=#{row.refundedAt},update_time=#{row.updateTime},version=version+1 WHERE id=#{row.id} AND version=#{expectedVersion}</update>
+ <select id="selectDue" resultType="com.ruoyi.system.domain.flash.FlashDeliveryLineRefund">SELECT <include refid="columns"/> FROM flash_delivery_line_refund
+ WHERE status IN ('CREATED','PROCESSING','UNKNOWN')
+ AND (next_reconcile_at IS NULL OR next_reconcile_at &lt;= #{now}) AND (lease_until IS NULL OR lease_until &lt;= #{now}) ORDER BY next_reconcile_at, id LIMIT #{limit}</select>
+</mapper>

+ 6 - 1
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -12,6 +12,7 @@
         <result property="deliveryType" column="delivery_type"/>
         <result property="vehicleType" column="vehicle_type"/>
         <result property="status" column="status"/>
+        <!-- canAdminCancel 是服务层按站点计算的只读字段,不映射/写入数据库。 -->
         <result property="isDisplay" column="is_display"/>
         <result property="packageType" column="package_type"/>
         <result property="quantity" column="quantity"/>
@@ -171,13 +172,17 @@
         where id=#{id} and status=#{expectedStatus}
     </update>
 
-    <!-- 取消时保存完整审计字段,并以调用前读取的状态作为并发条件。 -->
+    <!-- 取消仅取件前;服务行锁和站点检查之外再加条件,防止旧入口绕过部分取件事实。 -->
     <update id="cancel">
         update flash_delivery_order
         set status='CANCELLED', cancelled_at=#{now}, cancel_reason=#{reason},
             cancel_operator_type=#{operatorType}, cancel_operator_id=#{operatorId},
             update_time=#{now}, version = version + 1
         where id=#{id} and status=#{expectedStatus}
+          and status in ('WAITING_ACCEPTANCE','ACCEPTED') and picked_up_at is null
+          and coalesce(picked_up_stop_count,0)=0
+          and not exists (select 1 from flash_delivery_order_stop s where s.order_id=#{id}
+              and s.stop_type='PICKUP' and (s.status='PICKED_UP' or s.picked_up_at is not null))
     </update>
 
     <!-- 定时任务只扫描达到 24 小时阈值且仍处于已送达状态的订单。 -->

+ 27 - 0
ruoyi-system/src/main/resources/mapper/infouser/InfoRiderLinePayMapper.xml

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.InfoRiderLinePayMapper">
+ <sql id="columns">c.id, c.credential_version AS credentialVersion,
+ c.channel_id AS channelId, c.environment, c.credential_status AS credentialStatus,
+ c.is_enabled AS isEnabled, c.current_rider_id AS currentRiderId, c.verify_return_code AS verifyReturnCode,
+ c.verified_time AS verifiedTime, c.create_time AS createTime, c.update_time AS updateTime</sql>
+ <select id="lockRider" resultType="java.lang.Long">SELECT user_id FROM info_user WHERE user_id=#{riderId} FOR UPDATE</select>
+ <select id="selectCurrentByRiderId" resultType="com.ruoyi.system.domain.InfoRiderLinePay">
+ SELECT <include refid="columns"/>, c.rider_id AS riderId, c.channel_secret AS channelSecret FROM info_rider_line_pay c WHERE c.current_rider_id=#{riderId}
+ </select>
+ <select id="selectMaxVersionByRiderId" resultType="java.lang.Integer">SELECT MAX(credential_version) FROM info_rider_line_pay WHERE rider_id=#{riderId}</select>
+ <update id="clearCurrent">UPDATE info_rider_line_pay SET current_rider_id=NULL,is_enabled=0,update_time=#{now} WHERE id=#{id} AND current_rider_id=#{riderId}</update>
+ <!-- 列表绝不查询 Secret,不能靠前端隐藏保护密钥。 -->
+ <select id="selectRiderList" resultType="com.ruoyi.system.domain.vo.InfoRiderLinePayVo">
+ SELECT u.user_id AS riderId,u.user_name AS userName,u.phone,<include refid="columns"/>,CASE WHEN c.id IS NULL THEN 0 ELSE 1 END AS hasCredential
+ FROM info_user u LEFT JOIN info_rider_line_pay c ON c.current_rider_id=u.user_id
+ WHERE u.user_type='2' AND u.del_flag='0'
+ <if test="userName != null and userName != ''">AND u.user_name LIKE CONCAT('%',#{userName},'%')</if>
+ ORDER BY u.user_id DESC
+ </select>
+ <select id="selectRiderDetail" resultType="com.ruoyi.system.domain.vo.InfoRiderLinePayVo">
+ SELECT <include refid="columns"/>, u.user_id AS riderId,u.user_name AS userName,u.phone,c.channel_secret AS channelSecret,
+ CASE WHEN c.id IS NULL THEN 0 ELSE 1 END AS hasCredential
+ FROM info_user u LEFT JOIN info_rider_line_pay c ON c.current_rider_id=u.user_id WHERE u.user_id=#{riderId} AND u.user_type='2' AND u.del_flag='0'
+ </select>
+</mapper>

+ 28 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/FlashLinePayMapperTest.java

@@ -0,0 +1,28 @@
+package com.ruoyi.system.mapper;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+import java.util.*;
+import static org.junit.jupiter.api.Assertions.*;
+class FlashLinePayMapperTest {
+ private Configuration configuration(String name) throws Exception {
+     String resource="mapper/flash/FlashDeliveryLine"+name+"Mapper.xml";Configuration config=new Configuration();
+     try(var stream=getClass().getClassLoader().getResourceAsStream(resource)){new XMLMapperBuilder(stream,config,resource,config.getSqlFragments()).parse();}return config;
+ }
+ @Test void pickupQueriesEmitCurrentReads() throws Exception {
+     Configuration payments=configuration("Payment"),refunds=configuration("Refund");
+     String p="com.ruoyi.system.mapper.flash.FlashDeliveryLinePaymentMapper.selectLatestForUpdate";
+     String r="com.ruoyi.system.mapper.flash.FlashDeliveryLineRefundMapper.selectByPaymentIdForUpdate";
+     assertTrue(payments.hasStatement(p));assertTrue(refunds.hasStatement(r));
+     assertTrue(payments.getMappedStatement(p).getBoundSql(Map.of("orderId",1L)).getSql().trim().endsWith("FOR UPDATE"));
+     assertTrue(refunds.getMappedStatement(r).getBoundSql(Map.of("paymentId",22L)).getSql().trim().endsWith("FOR UPDATE"));
+ }
+ @Test void recoverySelectsOldestDueWorkBeforeId() throws Exception {
+     for(String kind:List.of("Payment","Refund")){
+         Configuration config=configuration(kind);
+         String sql=config.getMappedStatement("com.ruoyi.system.mapper.flash.FlashDeliveryLine"+kind+"Mapper.selectDue")
+                 .getBoundSql(Map.of("now",new Date(),"limit",20)).getSql().replaceAll("\\s+"," ");
+         assertTrue(sql.contains("ORDER BY next_reconcile_at, id LIMIT"),sql);
+     }
+ }
+}

+ 16 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/InfoRiderLinePayMapperTest.java

@@ -0,0 +1,16 @@
+package com.ruoyi.system.mapper;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+import java.util.Map;
+import static org.junit.jupiter.api.Assertions.*;
+class InfoRiderLinePayMapperTest {
+ @Test void listDoesNotSelectSecretsButAuthorizedDetailCanReadCurrentVersion() throws Exception {
+     String resource="mapper/infouser/InfoRiderLinePayMapper.xml";Configuration config=new Configuration();
+     try(var stream=getClass().getClassLoader().getResourceAsStream(resource)){new XMLMapperBuilder(stream,config,resource,config.getSqlFragments()).parse();}
+     String prefix="com.ruoyi.system.mapper.InfoRiderLinePayMapper.";
+     String list=config.getMappedStatement(prefix+"selectRiderList").getBoundSql(Map.of("userName","rider")).getSql();
+     assertFalse(list.contains("channel_secret"));assertTrue(list.contains("current_rider_id=u.user_id"));
+     String detail=config.getMappedStatement(prefix+"selectRiderDetail").getBoundSql(Map.of("riderId",10L)).getSql();assertTrue(detail.contains("channel_secret"));
+ }
+}

+ 10 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/PaymentMethodGateServiceTest.java

@@ -50,6 +50,15 @@ class PaymentMethodGateServiceTest {
     private PosStoreLinePayMapper linePayMapper;
     private PosStoreMapper storeMapper;
     private static ConfigurableListableBeanFactory originalBeanFactory;
+    @Test
+    void knownRiderLinePayRequiresEnabledCredentialsButUnassignedOrderDoesNot() {
+        IInfoRiderLinePayService credentials = mock(IInfoRiderLinePayService.class);
+        org.springframework.test.util.ReflectionTestUtils.setField(service, "riderLinePayService", credentials);
+        assertThrows(ServiceException.class, () -> service.assertUsable("3", PaymentMethodGateService.GateScope.RIDER_FLASH, null, 8L));
+        assertDoesNotThrow(() -> service.assertUsable("3", PaymentMethodGateService.GateScope.RIDER_FLASH, null, null));
+        when(credentials.getEnabledCurrent(8L)).thenReturn(new com.ruoyi.system.domain.InfoRiderLinePay());
+        assertDoesNotThrow(() -> service.assertUsable("3", PaymentMethodGateService.GateScope.RIDER_FLASH, null, 8L));
+    }
 
     @BeforeAll
     static void initializeMessageSource() {
@@ -86,6 +95,7 @@ class PaymentMethodGateServiceTest {
         linePayMapper = mock(PosStoreLinePayMapper.class);
         storeMapper = mock(PosStoreMapper.class);
         service = new PaymentMethodGateService(configMapper, userMapper, bankCardMapper, omgMapper, linePayMapper, storeMapper);
+        org.springframework.test.util.ReflectionTestUtils.setField(service, "riderLinePayService", mock(IInfoRiderLinePayService.class));
         // 默认:平台无任何开关行(缺行=开),避免各用例重复 stub
         when(configMapper.selectList(any())).thenReturn(List.of());
     }

+ 59 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/InfoRiderLinePayServiceTest.java

@@ -0,0 +1,59 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoRiderLinePay;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.dto.RiderLinePayCredentialDto;
+import com.ruoyi.system.mapper.InfoRiderLinePayMapper;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class InfoRiderLinePayServiceTest {
+    private org.mockito.MockedStatic<com.ruoyi.common.utils.MessageUtils> messages;
+    @org.junit.jupiter.api.BeforeEach void messages() {
+        messages = mockStatic(com.ruoyi.common.utils.MessageUtils.class);
+        messages.when(() -> com.ruoyi.common.utils.MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+    }
+    @org.junit.jupiter.api.AfterEach void closeMessages() { messages.close(); }
+    private final InfoRiderLinePayMapper mapper = mock(InfoRiderLinePayMapper.class);
+    private final InfoUserMapper users = mock(InfoUserMapper.class);
+    private final InfoRiderLinePayServiceImpl service = new InfoRiderLinePayServiceImpl(mapper, users, "SANDBOX");
+
+    @Test void nonRiderRejectedBeforeSave() {
+        InfoUser merchant = rider(); merchant.setUserType("1");
+        when(users.selectById(10L)).thenReturn(merchant);
+        assertThrows(ServiceException.class, () -> service.saveVerifiedCredential(dto(), "SANDBOX", "1150", "OK"));
+        verify(mapper, never()).insert(any(InfoRiderLinePay.class));
+    }
+    @Test void environmentMismatchNotEnabled() {
+        when(users.selectById(10L)).thenReturn(rider());
+        InfoRiderLinePay row = credential(); row.setEnvironment("PRODUCTION");
+        when(mapper.selectCurrentByRiderId(10L)).thenReturn(row);
+        assertNull(service.getEnabledCurrent(10L));
+    }
+    @Test void disabledRiderCannotBeEnabled() {
+        when(mapper.lockRider(10L)).thenReturn(10L);
+        InfoUser row = rider(); row.setStatus("1"); when(users.selectById(10L)).thenReturn(row);
+        when(mapper.selectCurrentByRiderId(10L)).thenReturn(credential());
+        assertThrows(ServiceException.class, () -> service.setCurrentEnabled(10L, true));
+    }
+    @Test void rotationKeepsOldVersion() {
+        when(users.selectById(10L)).thenReturn(rider()); when(mapper.lockRider(10L)).thenReturn(10L);
+        InfoRiderLinePay old = credential(); old.setId(21L); old.setCredentialVersion(2);
+        when(mapper.selectCurrentByRiderId(10L)).thenReturn(old);
+        when(mapper.selectMaxVersionByRiderId(10L)).thenReturn(2);
+        when(mapper.clearCurrent(eq(21L), eq(10L), any())).thenReturn(1);
+        when(mapper.insert(any(InfoRiderLinePay.class))).thenReturn(1);
+        InfoRiderLinePay saved = service.saveVerifiedCredential(dto(), "SANDBOX", "1150", "No history");
+        assertNotNull(saved); assertEquals(3, saved.getCredentialVersion());
+        assertEquals("fake-new-secret", saved.getChannelSecret());
+        assertEquals("fake-old-secret", old.getChannelSecret());
+        assertEquals(10L, saved.getCurrentRiderId());
+    }
+    private static InfoUser rider() { InfoUser r = new InfoUser(); r.setUserId(10L); r.setUserType("2"); r.setStatus("0"); r.setDelFlag("0"); return r; }
+    private static RiderLinePayCredentialDto dto() { RiderLinePayCredentialDto d = new RiderLinePayCredentialDto(); d.setRiderId(10L); d.setChannelId("fake-channel"); d.setChannelSecret("fake-new-secret"); return d; }
+    private static InfoRiderLinePay credential() { InfoRiderLinePay r = new InfoRiderLinePay(); r.setRiderId(10L); r.setChannelId("fake-channel"); r.setChannelSecret("fake-old-secret"); r.setEnvironment("SANDBOX"); r.setIsEnabled(1); r.setCredentialStatus("VERIFIED"); return r; }
+}

+ 4 - 0
specs/019-line-pay/plan.md

@@ -235,3 +235,7 @@ foodie-admin-vue/
 ## Post-Design Constitution Re-check
 
 `.specify/memory/constitution.md` 仍为未填充模板,没有可执行的额外原则。按仓库规则复核后,设计保持 `ruoyi-admin -> ruoyi-system` 依赖方向、明确 Controller DTO、商家门店归属鉴权、SQL 只写 `updatesql/sql.md`、不修改废弃支付代码、Offline 后端单一范围和敏感数据脱敏;未发现需要例外说明的门禁冲突。
+
+## 2026-10-10 骑手闪送 Online 计划状态
+
+本增量的主流程及取消边界已经用户确认:任意站点取件后用户和平台均不能取消;取件前 LINE Pay 已付款取消自动全额退款,未知和迟到付款须核实并补偿。详见 rider-flash-online-spec.md。实施计划见 [rider-flash-online-plan.md](rider-flash-online-plan.md),对应任务 T061 至 T065,当前已按确认计划原地实施,进入最终审查及验证。计划覆盖支付与取消/取件并发保护、历史订单兼容、平台取消资格展示及关键业务注释;不创建分支,不执行数据库迁移,不自动提交。

+ 3 - 0
specs/019-line-pay/quickstart.md

@@ -125,3 +125,6 @@ Sandbox cannot prove every App handoff behavior. Before production enablement, v
 - current production credentials complete an end-to-end TWD payment and full refund.
 - 业务方确认该 Channel 已开通 Offline API,并用真实客户 My Code 验证 `AUTH_READY/COMPLETE/CANCEL/FAIL` 收敛。
 - 验证 `paymentProvider` 的 `TSP/EPI` 原值;若生产返回其他非空值仍保存并允许完成,缺失或空值进入人工核对。
+# 骑手闪送 Online(2026-10-10 增量)
+
+接入见 [App/部署文档](../../docs/flash-delivery-line-pay-app-integration.md)。先手工迁移三张表与菜单,再配置 `LINE_PAY_FLASH_CONFIRM_URL/LINE_PAY_FLASH_CANCEL_URL` 公网 HTTPS 地址和环境一致的 SDK 基址;授权平台配置骑手凭证后联调。订单 `payType=3` 接单后才支付,可信到账才可取件,取件前取消全额退款,任一站取件后不能取消。未执行真实 LINE 或数据库验收,工作区未提交。

+ 103 - 0
specs/019-line-pay/rider-flash-online-plan.md

@@ -0,0 +1,103 @@
+# 骑手闪送 LINE Pay Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for native execution, or superpowers:subagent-driven-development only if the user explicitly chooses delegation. Steps use checkbox syntax. Do not implement before this written plan is reviewed.
+
+**Goal:** 用户在骑手接单后通过该骑手的 LINE Pay 付款,支付成功才可取件,取件前取消全额退款,任意站已取后禁止取消。
+
+**Architecture:** 骑手凭证、闪送支付尝试与退款分别持久化,不复用外卖流水关联。复用 LINE Pay v4 客户端;网络副作用采用持久化意图、独立外部请求及条件更新,订单行锁仅保护短事务。闪送入口接入独立支付服务,保持普通外卖及现金/转账收款流程。
+
+**Tech Stack:** JDK 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Redisson;Vue 2、Element UI、四语 i18n。
+
+**Spec:** [rider-flash-online-spec.md](rider-flash-online-spec.md),原 LINE Pay 规格第 17 节及闪送 FR-111 至 FR-115。
+
+## Global Constraints
+
+- 当前 `test-202609v2` 原地实施;不创建或切换分支/工作树。不自动提交或推送,等待用户指令。
+- 不修改 App、云函数、普通外卖资金流、OMG、废弃支付代码、钱包或提现;App 只提供接入文档。
+- `ruoyi-admin -> ruoyi-system`;外部 HTTP 编排在 admin,持久化与闸门在 system。Controller 明确 DTO、请求注解及权限,不用 Map 入参或 Bean Validation。
+- 仅登记 SQL 至 `updatesql/sql.md`,包括表、索引、字段说明和菜单权限;不执行数据库迁移或回写历史资金事实。
+- 密钥不写日志/审计、测试样例或文档;平台详情授权策略沿用门店配置,列表不返回 Secret。测试只用明确假的凭证,不使用用户提供的数据库密码或 token。
+- 保留 CRLF/编码及原有脏文件。关键支付副作用、凭证版本、取消/取件竞态写原因型注释;平台文字四语,后端补齐现有语言资源。
+- 未验证的测试、真实 MySQL 并发、Sandbox 和部署不宣称通过;进度使用未开始/实现中/已提交/已验证。
+
+## Review Focus
+
+1. 同时支付、取消或取件:共享订单行锁和固定锁序,取件后绝不能取消;任务 4 测试部分取件与竞争失败路径。
+2. 请求或 Confirm 已发送但进程崩溃:活跃尝试与租约保留,恢复只读核实,不重复扣款;任务 2/3 测试失联与租约过期。
+3. 凭证轮换/禁用以及环境不一致:新支付拒绝,历史查询退款固定原版本;任务 1/3 测试原版本归属与历史退款。
+4. 回跳伪造、越权和交易号精度:字符串保存 LINE 交易号,校验已保存的绑定;任务 2 测试错误绑定、他人订单和浏览器假成功。
+5. 旧订单缺站点或计数不一致:PICKED_UP/DELIVERED 直接禁止取消,站点事实优先;任务 4 测试旧数据与平台资格失效。
+
+## 固定契约与文件职责
+
+- 新表 `info_rider_line_pay`:不可变凭证版本;可空唯一 `current_rider_id` 保证一个当前版本。环境来自服务器,界面只读;轮换时只变更当前标记,不覆盖旧密钥。新支付要求用户类型 2、账号有效及当前凭证启用且环境匹配。
+- 新表 `flash_delivery_line_payment`:订单 ID、riderId、credentialId、服务端金额/TWD、唯一 lineOrderId/transactionId、可空唯一 activeOrderId、状态、版本、租约及下次核实/截止时间。订单 1:N 尝试;未知结果阻断新尝试。
+- 新表 `flash_delivery_line_refund`:paymentId 唯一、全额金额、状态、网关退款交易号、版本、租约及恢复时间;支付 PAID 事实不被退款覆盖。
+- 支付状态 `CREATED/REQUESTING/WAITING_AUTH/CONFIRMING/REQUEST_UNKNOWN/CONFIRM_UNKNOWN/PAID/FAILED/CANCELLED/EXPIRED/MANUAL_REVIEW`;只有网关明确证明的终止尝试可释放活跃键,浏览器取消不是证据。
+- 退款状态 `CREATED/PROCESSING/UNKNOWN/REFUNDED/FAILED/MANUAL_REVIEW`;UNKNOWN 与过期 PROCESSING 只读核实,不盲重发退款。不能证明结果时进入人工核对,不伪造成功。
+- 闪送原 `paymentStatus`:现金/转账保持 0/1;LINE Pay 0=未核实到账、1=已支付、2=已全额退款。SQL 修改列说明,不根据旧收款标记伪造 LINE 支付流水;历史 payType=3 无可信流水时禁止取件并提示核对。
+- `FlashLinePayPaymentView`:`Long orderId/paymentId`、`String status/refundStatus`、`String paymentUrl`;无尝试返回 status=NOT_STARTED,refundStatus 无退款时为空。paymentUrl 只返回寄件付款人,骑手查询仅返回状态;敏感流水/密钥不进 App 响应。
+- 新 App API:`POST /system/flashDelivery/orders/{id}/linePay/create`(明确空业务 DTO,不接收金额);`GET /system/flashDelivery/orders/{id}/linePay/status`。仅寄件人发起付款;寄件人及本单骑手可读取状态,不返回其他人订单。
+- 新匿名回跳:`GET /pay/line/flash/confirm` 与 `/cancel`,显式 orderId(LINE 订单号)、transactionId 字符串参数;仅定位已绑定尝试并触发核实,不信任浏览器支付结果。App 回链沿用既有 payment/result,加 `orderSource=FLASH`,orderId 为闪送 ID,文档要求前端重新查询。
+- `LinePayClient.request(credential, request, confirmUrl, cancelUrl)` 重载允许服务端指定闪送回跳;原双参数签名继续使用原配置。新增 `line-pay.flash-confirm-url/flash-cancel-url` 部署配置,无有效 HTTPS 回跳时阻止新闪送支付,不改变外卖配置。
+- 官方契约依据:[Online API v4](https://developers-pay.line.me/online-api-v4)、[付款请求](https://developers-pay.line.me/online-api-v4/request-payment)、[退款](https://developers-pay.line.me/online/handle-refund)。使用 Request/Check/Confirm/Retrieve/Refund,不新增 Capture/Void。
+
+## Task 1(T061):平台骑手凭证闭环
+
+**Files:** 新增 system 下 `domain/InfoRiderLinePay.java`、`domain/dto/RiderLinePayCredentialDto.java`、`domain/dto/RiderLinePayToggleDto.java`、`domain/vo/InfoRiderLinePayVo.java`、`mapper/InfoRiderLinePayMapper.java`、`resources/mapper/infouser/InfoRiderLinePayMapper.xml`、`service/IInfoRiderLinePayService.java`、`service/impl/InfoRiderLinePayServiceImpl.java`;admin 下新增 `com/ruoyi/app/pay/RiderLinePayController.java`。平台新增 `src/api/infouser/riderLinePay.js`、`src/views/infouser/riderPayment/index.vue`、`tests/rider-line-pay.test.cjs`;迁移与菜单登记至 updatesql/sql.md,四个 src/api/language 文件新增同名 key。
+
+**Interfaces:** `getCurrent(Long riderId)`、`getEnabledCurrent(Long riderId)`、`getById(Long credentialId)` 返回 InfoRiderLinePay;`saveVerifiedCredential(RiderLinePayCredentialDto dto, String environment, String returnCode, String returnMessage)` 返回版本;`setCurrentEnabled(Long riderId, boolean enabled)` 返回 int。列表/详情沿用门店分页 VO 模式。平台路径 `/system/riderLinePay`,list/detail/saveCredentials/toggleEnable 分别采用 GET/GET/PUT/PUT;权限 `infouser:riderLinePay:list/query/saveCredentials/toggleEnable`。
+
+- [x] 编写 `InfoRiderLinePayServiceTest` 与 `RiderLinePayControllerTest`:`nonRiderRejectedBeforeProbe` 验证非骑手 0 网关调用;`invalidProbePreservesCurrent` 验证原版本不变;`rotationKeepsOldVersion` 验证旧凭证可查;`listDoesNotExposeSecret` 验证列表无 Secret;`environmentMismatchNotEnabled` 验证环境错配不可新收款。
+- [x] 运行上述定向测试确认新增行为未实现导致失败;记录预期业务失败,不能把缺依赖/语法错误当红测。
+- [x] 一批实现模型、XML、服务、控制器、手工 SQL、页面及四语。先校验骑手再只读随机订单 probe;probe 1150 或 0000 且空 info 数组通过,其余失败/未知不替换原配置。保存/启用共用骑手行锁,唯一键兜底。保存接口禁用请求/响应操作日志原文,避免 Secret 进入 @Log 审计。
+- [x] 重跑定向测试及 `node --test tests/rider-line-pay.test.cjs`,核对配置列表、保存、禁用、权限按钮及环境只读;保留可独立演示的平台配置闭环,暂不开放真实闪送支付。
+
+## Task 2(T062):接单后支付、回跳与支付事实
+
+**Files:** 新增 system `domain/flash/FlashDeliveryLinePayment.java`、`mapper/flash/FlashDeliveryLinePaymentMapper.java`、`resources/mapper/flash/FlashDeliveryLinePaymentMapper.xml`;admin `com/ruoyi/app/flashdelivery/payment/FlashLinePayService.java`、`FlashLinePayFactService.java`、`FlashLinePayController.java`、`FlashLinePayReturnController.java`、`FlashLinePayReturnPageRenderer.java`,DTO `FlashLinePayCreateRequest.java`、`FlashLinePayPaymentView.java`。修改 LinePayClient.java、LinePayProperties.java,应用非敏感配置、updatesql/sql.md 及 i18n。
+
+**Interfaces:** `FlashLinePayService.create(Long userId, Long orderId)`、`query(Long callerId, Long orderId)` 返回 FlashLinePayPaymentView;`reconcile(Long paymentId, String source)` 返回 void;`findByLineOrderId(String lineOrderId, String transactionId)` 返回持久化尝试。`FlashLinePayFactService.applyPaidFact(Long paymentId, String transactionId, long paidAmount, Date paidAt)` 返回 boolean,事务内先锁订单再更新尝试与 paymentStatus。
+
+- [x] 编写 `FlashLinePayServiceTest`、`FlashLinePayControllerTest`、`FlashLinePayFactServiceTest` 和 LinePayClientTest 增量:`unacceptedOrderCannotPay`、`crossUserCannotPay`、`serverAmountAndRiderAreUsed`、`serverAmountOverflowRejected`、`duplicateCreateUsesExistingAttempt`、`requestTimeoutDoesNotCreateSecondAttempt`、`confirmTimeoutOnlyRetrieves`、`forgedReturnCannotMarkPaid`、`transactionIdRetainsExactDigits`、`riderQueryDoesNotExposePaymentUrl`;断言未经核实 paymentStatus 不为 1,客户端金额/骑手不能覆盖后端,金额溢出时无网关调用,重复网关 Request/Confirm 至多一次。
+- [x] 运行定向测试证明新增契约失败,再实现持久化意图→事务外网络→CAS 结果。金额用 long 精确转 SDK int,必须正整数 TWD,溢出拒绝;生成唯一闪送 lineOrderId 并绑定骑手/凭证版本。异常事实保留人工核对,不放行取件。
+- [x] 实现服务端 Check 判断认证状态后 Confirm,超时只 Retrieve;校验订单号、交易号、payInfo 金额合计及网关明确的支付事实,币种以已保存请求为依据,响应提供币种时额外核对。不要求响应具有官方未保证字段。事实落库不能使取消订单恢复 ACCEPTED。
+- [x] 实现专属回跳 URL 与安全返回页,App 状态查询触发/返回恢复结果;浏览器 cancel 不取消履约订单。重跑定向测试及原 LinePayClientTest/LinePayControllerTest,证明普通外卖原回跳不变。
+
+## Task 3(T063):取消全额退款与持久恢复
+
+**Files:** 新增 system `domain/flash/FlashDeliveryLineRefund.java`、`mapper/flash/FlashDeliveryLineRefundMapper.java`、`resources/mapper/flash/FlashDeliveryLineRefundMapper.xml`;admin `com/ruoyi/app/flashdelivery/payment/FlashLinePayRefundService.java`、`com/ruoyi/app/task/FlashLinePayReconcileTask.java`。修改任务 2 的支付事实服务及 updatesql/sql.md。
+
+**Interfaces:** `FlashLinePayFactService.ensureCancellationRefundIntent(Long orderId)` 返回 void,在订单锁内创建唯一退款意图或登记未决付款补偿;`FlashLinePayRefundService.reconcile(Long refundId, String source)` 返回 void。定时任务 `reconcile()` 分批处理支付与退款,使用独立 Redisson 任务锁和行租约,沿用既有 LINE 恢复批量/租约配置,不更改外卖任务。
+
+- [x] 编写 `FlashLinePayRefundServiceTest`、`FlashLinePayReconcileTaskTest`:`paidCancellationCreatesOneFullRefund` 验证 paymentId 唯一;`latePaymentOnCancelledOrderCreatesRefund` 验证取消不恢复、真实扣款自动补偿;`refundTimeoutOnlyRetrieves` 验证 UNKNOWN 不二次 Refund;`rotatedCredentialRefundUsesOriginalVersion` 验证原密钥;`expiredLeaseRequiresReadOnlyRecovery` 验证崩溃后不重扣;`refundFactDoesNotErasePaidFact` 验证支付仍 PAID,订单收款状态仅退款证实后为 2。
+- [x] 运行上述红测后,一批实现退款记录、事先只读证据核实、Full Refund 及成功事实。取消前未知支付保留活跃阻断,恢复查询若发现付款即补偿;本地退款意图与取消原子提交,不依赖仅内存的提交后回调。
+- [x] 请求失联/响应不明确只查询退款证据;金额、交易号或原凭证不匹配进入人工核对。取消先完成不得再发起新 Confirm;在途 Confirm 只读恢复真实扣款。任务不得把截止时间达到当作未扣款证据,不能仅本地超时释放活跃键。
+- [x] 重跑退款、事实及任务测试;核对任务异常单笔隔离、恢复有界、所有资金调用在数据库事务外,不对 TestTask 作修改。
+
+## Task 4(T064):闪送接单、取件与取消统一门禁
+
+**Files:** 修改 system PaymentMethodGateService.java、domain/flash/FlashDeliveryOrder.java、mapper/flash/FlashDeliveryOrderMapper.java/XML;admin FlashDeliveryApplicationService.java、FlashDeliveryStateMachine.java、DTO FlashDeliveryAdminOrderDetailView.java/FlashDeliveryOrderView.java。平台修改 src/views/flashDelivery/shared.js、orders/index.vue、orders/OrderDetail.vue、multiPickup.js 及相关四语/测试。
+
+**Interfaces:** 闸门 RIDER_FLASH 在 payeeUserId 非空时检查任务 1 凭证,未知骑手仅服从平台开关;保留现有支付选择。新增 `FlashLinePayFactService.assertPickupPaid(FlashDeliveryOrder order)` 返回 void,只验证可信 PAID 流水并无退款阻断,不仅检查 paymentStatus。平台列表订单返回只读非持久化 `Boolean canAdminCancel`,详情同名字段;资格计算复用短方法 `canCancelBeforePickup(FlashDeliveryOrder order, List<FlashDeliveryOrderStop> stops)`。
+
+- [x] 在 FlashDeliveryApplicationServiceTest、FlashDeliveryStateMachineTest、PaymentMethodGateServiceTest 编写 `partiallyPickedOrderCannotAdminCancel`、`legacyPickedStatusCannotCancel`、`noStationFactsCannotDefaultToCancellable`、`linePayWithoutVerifiedPaymentCannotPickupAnyStop`、`manualReceiptCannotMarkLinePayPaid`、`riderWithoutCredentialCannotListOrAccept`、`cancelAndPickupConditionFailureDoesNotApplySideEffects`;断言取消拒绝无写入、任一取件入口不绕过付款、现金/转账原收款流程不变。
+- [x] 运行定向红测后收紧 canAdminCancel 仅 WAITING_ACCEPTANCE/ACCEPTED;行锁内检查 anyPicked 最新站点,历史状态和异常缺站点安全拒绝。已取物品处置旧数据仍只读展示,取消表单不再出现取件后处置入口,保留未取件取消原因/问题站/处理结果。取消成功事务内调用任务 3 意图创建;真实网络退款交恢复执行。
+- [x] 平台列表批量读取站点事实生成 canAdminCancel,避免逐单查询;详情按相同规则生成。按钮只在字段严格为 true 且权限匹配时出现,缺字段安全隐藏;弹窗读取详情重新确认,后端最终重校验。页面显示支付与退款状态但不能手工伪造 LINE 收款。
+- [x] 重跑上述测试及平台 flash-delivery/flash-multi-pickup 测试,新增 `acceptedButPartlyPickedHidesCancel`、`missingEligibilityHidesCancel`、`staleDialogCannotSubmitCancel`、`allUnpickedCanCancel` 断言;新增 API状态字段/注释同步 Entity、XML、DTO、Controller、i18n 与 SQL 列说明。
+
+## Task 5(T065):接入文档、统一验证及交付
+
+**Files:** 新增 docs/flash-delivery-line-pay-app-integration.md;更新 specs/019-line-pay/spec.md、plan.md、tasks.md、quickstart.md、本增量规格/计划;更新 specs/024-flash-delivery 与 specs/031-pay-method-config 的 spec.md/plan.md/tasks.md 兼容说明。新增/更新测试保持任务 1 至 4 的实际类名,不另建重复测试框架。
+
+- [x] 接入文档给出 payType=3 下单→接单→create→LINE认证→服务端核实→取件;列明精确路径、token、DTO、状态字段、回链 orderSource=FLASH 与查询流程。说明失败/未知不能重复付款、退款不等于取消、密钥仅服务端保存、历史线上单须核对;请求示例使用虚构信息,不写实际密钥。
+- [x] 在 PowerShell 当前命令环境临时设置 JAVA_HOME 为 `C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1` 并将 bin 加入 PATH;先验证 java -version。每任务定向命令统一采用 `mvn -pl ruoyi-admin -am '-Dtest=<该任务全部测试类名,逗号分隔>' -Dsurefire.failIfNoSpecifiedTests=false test`,system 类也随 reactor 执行,不改全局环境。
+- [x] 最终执行 `mvn -pl ruoyi-admin -am test` 和 `mvn -pl ruoyi-admin -am -DskipTests package`,要求 BUILD SUCCESS、新鲜报告 0 失败/错误;记录实际项数,不沿用历史数字。平台目录执行 `node --test tests/rider-line-pay.test.cjs tests/flash-delivery.test.cjs tests/flash-multi-pickup.test.cjs`、改动 Vue/脚本及四语解析检查;有依赖时执行 npm run build:stage,依赖缺失则明确未执行,不擅自安装升级。
+- [x] 检查 diff、范围、CRLF/编码、Controller签名、模块依赖、SQL仅登记、敏感值脱敏及原因型注释。真实 MySQL多连接竞争、手工迁移、LINE Sandbox商户、浏览器/App联调及生产开通列为独立未验证项,由用户提供环境后执行,不在本轮偷偷调用真实资金接口。
+- [x] 按实际结果更新进度;仅用户要求 commit push 时暂存本任务文件并核对清单,报告 SHA/推送分支/验证结果。工作区原有修改或同时发生的其他功能不混入提交。
+
+## 依赖与审阅结论
+
+T061 → T062 → T063 → T064 → T065,采用一次原地实施闭环,不将未完成的接单后付款路径视为已交付;计划未要求创建 Agent。主流程、凭证、App边界及注释覆盖 US-R1 至 US-R4;五项 Review Focus 均对应定向测试。
+
+执行方式已确认:本会话原地执行,不创建分支,不自动提交。T061–T065 本地实现及验证已收口,仍未提交/推送。后端全量 1140 项、平台 16 项及两端构建通过,最终独立只读审查 5 项 Important 均用失败→通过回归修复;没有启动实现分工或重复审查。实际结果见 tasks.md;不执行数据库迁移或真实资金调用,真实 MySQL 竞争及 App 联调另行验收。

+ 79 - 0
specs/019-line-pay/rider-flash-online-spec.md

@@ -0,0 +1,79 @@
+# 闪送用户向接单骑手支付 LINE Pay:增量规格
+
+日期:2026-10-10。状态:实现中(本地已验证,未提交)。分支:当前 `test-202609v2`,不创建或切换分支。
+
+## 目标与边界
+
+平台配置骑手自己的 LINE Pay Online 凭证;闪送用户在骑手接单后直接向该骑手付款。平台不代收,不引入钱包结算。普通外卖继续由用户付款给商家、商家取餐时给骑手配送费,本增量不调整该流程。
+
+骑手以个人身份开通 Online API 是业务方确认的接入前提;测试凭证与生产商户开通分别验收。App 不在修改范围,只交付接入文档。
+
+## 用户场景与验收
+
+### US-R1 平台维护骑手支付配置(P1)
+
+平台选择真实骑手,维护 channelId、channelSecret、环境及启用状态。沿用现有门店配置的权限与校验模式,但保存到独立骑手凭证记录,不混入门店配置。
+
+1. 非骑手账号不能建立骑手收款配置;无权限的平台用户不能读取或修改配置。
+2. 新凭证校验失败保留原有效配置;轮换不覆盖历史版本。
+3. 列表不返回 channelSecret;密钥不能进入普通日志、网关原文审计、测试样例或接入文档。详情的授权回显策略沿用现有门店配置,不额外改变全项目的密钥保存策略。保存凭证绕过通用 sessionStorage 重复提交缓存,页面保留防重复点击。
+4. 禁用配置阻止新的支付请求;已有交易仍能使用原凭证查询和处理退款,不切换到新密钥。
+5. 服务端环境必须与实际网关地址严格匹配,错配时骑手凭证探测及闪送资金调用均拒绝,不把生产凭证错误标为 Sandbox。
+
+### US-R2 接单后发起支付(P1)
+
+用户选择 `payType="3"` 创建闪送订单,订单仍先处于待接单;接单成功后用户发起支付。
+
+1. 未接单、他人订单或已取消订单在网关调用前拒绝支付。
+2. 用户仅传订单标识及必要支付操作参数;骑手、金额、TWD 币种和凭证版本取自后端事实。
+3. 只有已启用有效骑手凭证且接受该支付方式的骑手才能接 LINE Pay 订单;待抢列表与接单接口保持一致,接口重新校验。用户下单尚未确定骑手时,不要求用户指定骑手凭证。
+4. 同一订单最多一条阻断性活跃支付尝试。重复请求返回已有尝试;超时或结果未知时只读核实,不盲目创建第二次扣款请求。
+5. 接单后不新增改价或追加支付能力,保留现有仅待接单可改址、加小费的约束。
+
+### US-R3 服务端确认支付后取件(P1)
+
+1. 浏览器回跳或 App 上报成功不构成支付事实。后端经 LINE Pay 确认或查询,核对交易、订单、金额及币种后,才能写入已支付。
+2. 未支付、认证中、结果未知或异常待核对时,单点取件及任意站点取件都拒绝;多站点订单即使主状态仍为 ACCEPTED,也不得绕过检查。
+3. 骑手手工确认收款不能将 LINE Pay 订单标记为支付成功;现金及线下转账原确认入口保持原行为。
+4. 重复回跳、重复查询及恢复任务只落一次资金事实;不在支付确认时重复写订单完成收入账单。
+
+### US-R4 取消与资金闭环(P1,2026-10-10 用户确认)
+
+现状:用户仅可在任意站点实际取件前取消;平台原先支持取件后异常取消。此次收紧平台权限,不再允许用户或平台取消任意站点已经取件的订单;当前闪送取消没有线上支付退款,需要在本增量补齐。
+
+已确认规则:
+
+- 任意站点取件前取消:已经支付的 LINE Pay 订单自动全额退款;尚未完成支付但网关结果未知的订单先核实,若查到真实扣款则补偿退款,不把未知状态当作未扣款。
+- 任意站点取件后:用户和平台取消接口均拒绝。以实际站点已取件事实判定,不等待主状态进入 PICKED_UP;已送达及已完成同样禁止取消。
+- 此取消限制适用于全部闪送支付方式,不仅 LINE Pay。现金和线下转账的收款流程不变。
+- 损坏、丢失或无法送达由客服人工处理,不通过取消接口关闭订单,不自动退款;本期不新增客服工单、部分退款、取件后异常关闭或取消费用功能。
+- 支付与退款分别记录事实;退款失败或未知时保留可追踪状态并恢复核实,不把订单 CANCELLED 等同于已退款。
+- 用户及平台取消、各站点取件、支付成功落库共用订单级并发保护;先锁订单再检查最新站点事实。取件先成功则取消失败,取消先成功则取件失败;取消后的迟到付款只能进入退款补偿,不能恢复履约。
+
+验收:单站已取、多站仅已取 1/N 站、旧订单主状态为 PICKED_UP 或 DELIVERED 均拒绝用户和平台取消;全部未取的合法非终态订单可取消。平台按钮根据服务端取消资格展示,不能仅根据主状态判断;直接调用接口同样拒绝。取消与取件并发最终只能有一个成功,不产生“已取件又取消”的订单。
+
+## 数据与组件边界
+
+- 骑手凭证:骑手 ID、环境、凭证版本、启用和校验状态;支付引用不可变版本,轮换不破坏历史交易。
+- 闪送支付尝试:闪送订单 ID、收款骑手、凭证版本、服务端金额和币种、LINE 订单号/交易号、活跃键、支付事实及时间。不使用外卖订单 ID 关联闪送交易。
+- 退款意图及事实:取件前取消创建唯一全额退款意图,关联原支付和原凭证,不覆盖历史已付款事实;不得以退款结果未知为由重复提交资金副作用。
+- SDK 的签名、客户端及只读凭证校验能力复用;外卖支付编排不直接套用于闪送,不为本增量重构 OMG 或废弃支付代码。
+- 保持 ruoyi-admin 到 ruoyi-system 的依赖方向。Controller 显式 DTO/参数与 token 注解;业务错误及平台页面提供项目四语。
+- 数据库变更只登记到 updatesql/sql.md,由开发者手动执行;本次不连接数据库执行迁移。
+
+## 注释与交付要求
+
+注释说明“为什么”:为何锁定收款骑手及凭证版本、为何未知结果不能重扣、为何回跳不能作为付款凭据、为何每个取件入口都要校验、为何取消仍需核实资金、为何多站点只取一站就禁止平台取消。涉及支付副作用的竞态、条件更新及补偿边界必须有注释,不仅复述方法名。
+
+前端接入文档应包含下单 payType、接单后的支付入口、支付查询与回跳、未支付取件错误、支付/退款状态含义及重试限制;不提供真实密钥,不要求 App 上传密钥。
+
+## 验证与完成条件
+
+- 定向测试覆盖权限、收款方防伪、凭证轮换、重复支付、未知结果、可信支付确认、多站点取件及现金/转账兼容。
+- 取消退款测试纳入同一交付,覆盖单站、多站部分取件、历史状态、取消/取件竞态、取消后的迟到支付与退款未知结果,不以数据库改状态替代真实退款。
+- 执行 JDK 21 受影响模块构建、平台页面定向检查及四语核对;真实 Sandbox/生产验证不可用时明确列为未执行。
+- 交付前检查 diff、编码、换行和文件范围,不混入工作区原有修改。未运行验证不得宣称已验证,未提交不得宣称已提交。
+
+## 后续流程
+
+按 spec-kit 的 specify → plan → tasks → implement 推进:用户已确认本会话原地执行计划,顺延任务 T061 至 T065。本地 1140 项后端测试、16 项平台行为测试与两端构建通过,独立审查 5 项重要问题已回归修复;详情见 tasks.md。未执行数据库迁移、真实 MySQL 并发或真实资金联调,未修改 App,未提交/推送。

+ 20 - 0
specs/019-line-pay/spec.md

@@ -643,3 +643,23 @@ Header: token
 7. **Given** 支付金额不一致,**When** 后端处理,**Then** 持久化实际扣款额、不触发正常履约,并在同一事务创建以实际扣款额为金额的唯一全额退款意图;无论退款结果如何,原订单永久保持支付阻断,商家必须创建新订单。
 8. **Given** 已支付 Offline 流水需要退款,**When** 取消订单或平台退款流程处理,**Then** 使用 `lineOrderId` 调用 Offline Refund;Online 回归仍使用 `transactionId`。
 9. **Given** 任意日志或异常路径,**When** 请求结束,**Then** 数据库和日志中均不存在原始 `oneTimeKey`。
+
+## 17. 骑手收款与闪送 Online 增量(2026-10-10)
+
+本增量在当前 `test-202609v2` 分支维护,不创建或切换分支。此前仅支持门店凭证、仅面向外卖的约束继续适用于原接口;新增骑手凭证与闪送支付使用独立业务入口,不改变外卖资金流。详细规格见 [rider-flash-online-spec.md](rider-flash-online-spec.md)。
+
+### 已确认范围
+
+- 平台为骑手管理独立的 LINE Pay `channelId`、`channelSecret`,沿用现有 Online API v4 集成能力;骑手对应的商户开通资格由业务方与 LINE Pay 确认。
+- 闪送用户下单时选 LINE Pay(`payType="3"`),骑手接单后,用户才可向该骑手发起支付。
+- 后端以订单中的骑手、金额及币种确定收款与请求参数,不接受前端指定收款骑手、凭证或金额。
+- LINE Pay 支付成功后才允许取件,覆盖单点取件和多站点取件入口。现金及线下转账保持现有规则。
+- 任意站点实际取件后,用户和平台均不能取消,适用于全部闪送支付方式。取件前合法取消的已付款 LINE Pay 订单自动全额退款;未知或迟到支付先核实再补偿。取件后异常由客服人工处理,不自动退款、不新增异常关闭功能。
+- App 端由前端人员实现,本次提供后端接口及接入文档;不修改 App、普通外卖支付、云函数或平台钱包。
+- 新增关键代码必须说明凭证版本、幂等、支付事实校验、未知结果恢复、取件门禁及取消竞态的原因;不得在注释、文档、测试或日志中使用真实密钥。
+
+### 进度
+
+- 实现中:用户已确认原地执行计划,骑手凭证、接单后付款、可信到账/退款事实、持久恢复及取件/取消门禁已写入工作区;平台页面及四语同步。
+- 定向验证记录在 tasks.md;App 只提供 [接入文档](../../docs/flash-delivery-line-pay-app-integration.md),不修改 App。
+- SQL 仅登记,真实 LINE/MySQL 并发及部署未验证;本增量未提交或推送。

+ 24 - 1
specs/019-line-pay/tasks.md

@@ -1,4 +1,4 @@
-# Tasks: LINE Pay Online / Offline API v4 支付
+# Tasks: LINE Pay Online / Offline API v4 支付
 
 **Input**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/api.md`, `offline-merchant-scan-design.md`
 **Tests**: 本功能涉及支付、凭证、外部 HTTP 和并发状态,所有实现任务必须先添加失败测试。
@@ -158,3 +158,26 @@
 - 只有 `order_source=MERCHANT` 的新商家单门店订单可发起 Offline;用户端、历史、跨商家和多门店订单在网关调用前拒绝。
 - Online/Offline 共用唯一活跃门禁并只按 `payment_mode` 选择 Pay/Check/Refund 端点,任何未知资金结果不得盲目重复副作用。
 - 原始 `oneTimeKey` 在数据库、普通日志、异常、响应和网关审计中均不存在;金额不一致不履约、只创建一次全额退款且原订单不再支付。
+
+## 2026-10-10 骑手闪送 Online 增量进度
+
+- 实现中,用户已确认原地执行 rider-flash-online-plan.md;生产代码、平台配置/订单页面、SQL、测试与 App 接入文档已编写。
+- 2026-10-10 定向回归:system 26 项、admin 184 项通过;平台三组行为测试 15 项通过。后续新增恢复边界测试及最终全量/构建结果另记,不沿用历史数字。
+- 后续任务必须包含关键业务注释;本增量不创建分支,不修改 App,不混入原有工作区修改。
+- 取消门禁须覆盖用户及平台、全部支付方式和多站点部分取件;取件前 LINE Pay 取消须覆盖全额退款、未知资金核实及迟到支付补偿,不混淆订单取消和退款成功。
+
+### Phase 骑手闪送 Online(原地实施,实现中,未提交)
+
+- [x] T061 平台骑手 LINE Pay 独立凭证、版本及启用校验闭环,包含页面、权限、四语、手工 SQL 与定向测试。
+- [x] T062 接单后支付、专属回跳、可信支付事实、持久化活跃尝试及未知结果保护,包含 SDK 兼容测试。
+- [x] T063 取件前取消全额退款、迟到付款补偿与持久恢复任务,历史凭证固定,未知资金只读核实。
+- [x] T064 骑手凭证接单门禁、可信支付取件门禁、任意站已取禁止用户/平台取消、平台服务端资格展示及竞态失败/历史兼容测试。
+- [x] T065 App 接入文档、相关规格联动、JDK 21 定向/全量测试与模块构建、平台定向检查及按实际证据更新交付记录。
+
+### 本地最终验证(2026-10-10,未提交/推送)
+
+- 独立只读审查发现 5 项 Important、无 Critical/Minor:取件资金旧快照、补偿任务饥饿、已退款事实被截止任务降级、环境标签与网关错配、凭证进入浏览器重复提交缓存。统一修复,新增回归先失败后通过;Mapper 2 个失败断言、admin 6 个失败断言及真实请求拦截器缓存失败均有本地记录。
+- 最终 `mvn -pl ruoyi-admin -am test`:新鲜日志 164 个测试类、1140 项测试,0 失败/错误/跳过;`mvn -pl ruoyi-admin -am '-DskipTests' package`:BUILD SUCCESS。仅命令环境使用 JDK 21,无全局 Java 修改。
+- 平台 `node --test tests/rider-line-pay.test.cjs tests/flash-delivery.test.cjs tests/flash-multi-pickup.test.cjs`:16/16;`npm run build:stage` 成功。新增字段/四语、SQL与原因型注释同步,diff/换行最终复核。
+- 追加平台全部现有测试 `node --test tests/*.cjs`:25/25,0 失败/跳过。两仓库 CRLF/BOM 保留及 diff --check 通过,未混入原有 voice 接入文档。
+- 当前读的锁定 SQL、旧快照行为及超过一批的公平恢复已测试;未运行真实 MySQL 多连接竞争、SQL 迁移、真实骑手 Sandbox/生产付款退款、浏览器/App 联调,不把本地替身测试等同上线验收。没有修改 App、云函数或其他支付资金流。

+ 5 - 0
specs/024-flash-delivery/plan.md

@@ -768,3 +768,8 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 2. 两个事务完成入口在订单行锁和 `DELIVERED -> COMPLETED` 条件更新成功后共用 `recordRiderIncome`,直接调用账单 Mapper 写入全额收入及完成时间。写入失败抛国际化业务异常并回滚;不依赖确认收款,不调用任何钱包服务。
 3. `UserBillingController` 的日/周/月骑手账单按来源分别批量查询外卖和闪送订单;闪送查询同时约束当前骑手,保留分页顺序和原统计 SQL。`QsBillingDto` 补充闪送主键、小费与收款状态,通用账单响应也返回来源;旧 `WalletController.getUserBills` 仅过滤外卖关联查询的来源,余额/提现不变。
 4. TDD 覆盖四种完成操作人、重复/竞争失败、取消/未取完、账单写入失败、确认收款不生成账单,以及单独/混合来源的日周月详情;验证 XML 绑定新字段和完成时间。运行 JDK 21 定向测试、模块测试及构建,检查差异和换行;真实数据库事务/并发、SQL 迁移及 App 联调另列未验证。
+## 2026-10-10 骑手 LINE Pay 与取消边界增量
+
+按 019 rider-flash-online-plan.md 原地实施。骑手独立凭证、支付/退款表和恢复编排在 019;本规格接单复核收款资格,单点/逐站取件校验可信 PAID,禁止手工确认 LINE 收款。用户与平台取消共用订单锁和真实站点判断;平台列表批量生成 canAdminCancel,详情/按钮/弹窗同口径,缺事实安全拒绝。任一站已取件后不再平台取消,历史交接数据仍可读。本文较早的取件后人工取消/收款描述由本增量覆盖。
+
+取消和退款意图同事务,网络退款在事务外;迟到付款只补偿,不恢复履约。App 不修改,见 ../../docs/flash-delivery-line-pay-app-integration.md。SQL 仅登记,真实资金/数据库并发另行验证,当前实现中、未提交。

+ 10 - 0
specs/024-flash-delivery/spec.md

@@ -394,3 +394,13 @@
 - FR-108:记账不以 `payment_status=1` 为前提;生成账单不改变收款状态,“确认收款”也不再生成第二笔。账单表示完成订单收入,不是实际到账核验。
 - FR-109:现有骑手日/周/月账单列表及统计包含闪送收入;按 `orderSource` 区分 `FLASH`/`FOOD`,历史空来源的骑手订单账单按 `FOOD` 兼容。闪送详情只关联当前骑手的闪送订单,不关联外卖订单;返回 `flashOrderId`、订单号、金额、运费(总额减小费)、小费、公里距离、评分、支付方式及收款状态。账单列表保持原分页与排序。
 - FR-110:只针对上线后成功完成的订单,不自动补历史已完成订单,不修改外卖记账/钱包/提现规则。SQL 仅登记在 `updatesql/sql.md`,部署前由开发者执行;用户/骑手 App 页面不在本次范围内。
+
+## 2026-10-10:取件后统一禁止取消(实现中)
+
+此增量替代 US6、FR-022、FR-088、FR-092、FR-098 及历史平台取消场景中“取件后可由平台取消/关闭”的规则;代码与定向测试已写入工作区,最终审查及验证结果见 tasks.md。
+
+- FR-111:任意取件站点实际取件后,用户和平台均不能取消;多站点订单只取 1/N 站、主状态仍为 ACCEPTED 时也禁止。历史 PICKED_UP、DELIVERED、COMPLETED、CANCELLED 订单同样禁止取消。此门禁适用于全部支付方式。
+- FR-112:全部站点未取件的合法待接单/已接单订单维持取消能力及原因、审计要求;取消与取件必须在订单行锁内读取最新主状态及站点事实后判断。任一站取件先成功则取消失败,取消先成功则取件失败。
+- FR-113:平台取消按钮依据服务端取消资格展示,不仅依据主状态;缺少多站点事实时不得默认放行,取消接口必须重新校验。保留既有历史处置记录用于审计,不新增取件后异常关闭或客服工单功能;异常由客服人工处理,不自动退款。
+- FR-114:取件前取消的 LINE Pay 已付款订单自动全额退款,未知及迟到付款先核实再补偿;资金状态不与履约 CANCELLED 混同。此支付增量由 specs/019-line-pay/rider-flash-online-spec.md 维护,替代旧规格“不接入付款退款”的范围限制,仅针对新增闪送骑手 LINE Pay,不改变现金/转账收款及外卖支付。
+- FR-115:取件门禁、取消竞态、部分取件判断与退款补偿必须补充原因型注释;测试覆盖用户/平台、单站/多站、历史状态、并发及未知资金结果。当前实现中,本地测试与构建结果以 tasks.md 为准,真实数据库并发和资金联调另行验收。

+ 8 - 0
specs/024-flash-delivery/tasks.md

@@ -234,3 +234,11 @@
 - `mvn -pl ruoyi-admin -am -DskipTests package`:全部参与模块 BUILD SUCCESS。实际 JDK 为本机 `C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`,仅临时设置命令环境;规范中原 JDK 路径在本机不存在。
 - 独立只读审查未发现高/中严重度问题;已补旧 `/wallet/getUserBills` 的订单来源过滤及回归测试。保留 CRLF;使用 `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check` 检查(既有测试文件的 Git blob 本身含 CRLF)。
 - SQL 仅登记,不执行;未验证真实 MySQL 事务/多连接并发、App 展示、部署上线。提交与推送状态以 Git 记录为准。
+## Phase 24:骑手 LINE Pay 与取件前取消(2026-10-10,实现中)
+
+- [x] T118 接单服务器复核骑手收款就绪;每站取件校验可信 LINE 到账,禁止骑手手工确认线上收款。
+- [x] T119 用户/平台统一取件前取消,部分取件、旧主状态、缺站点安全拒绝,取消成功事务内登记全额退款意图;平台列表/详情提供 canAdminCancel。
+- [x] T120 平台取消按钮与弹窗复核服务端资格,移除取件后处置表单,保留历史审计展示;详情显示支付/退款状态,四语及定向测试同步。
+- [x] T121 联动 019 接入文档及最终全量/构建证据,数据库迁移与真实资金/多连接竞态另行验证。未提交/推送。
+
+Phase 24 本地验证:最终后端 164 类/1140 项测试、0 失败/错误/跳过,JDK 21 package 成功;平台 16/16、build:stage 成功。独立审查 5 项重要问题均回归修复,取件资金采用当前读、退款终态防降级、恢复按到期时间公平排队。详见 019/tasks.md;SQL、真实数据库竞态、真实支付退款及 App 联调未执行。

+ 1 - 0
specs/031-pay-method-config/plan.md

@@ -88,3 +88,4 @@ E:\QtwCode\foodie\foodie-store\src\views\PayMethodSettings.vue   # 新:商家支
 
 - **2026-09-20 自取/堂食现金支付(spec US7)**:现金(payType=4)从"完全不管控、仅商家建单"调整为"纳入商家勾选(info_user.pay_methods 新增 CASH 组)、仍无平台开关与就绪度";可用场景扩展到用户自取/堂食下单(外送单直传现金被闸门拒绝)。闸门新增带 orderType 上下文的 `assertUsableForOrder` 重载,原四处调用点行为不变。无 DDL。任务见 tasks.md Phase 10(T026-T030)。
 - **2026-09-28 商家PC联调修复与 DDL 补登记**:商家端联调暴露四项问题当日修复——①foodie-store 侧边菜单缺「支付设置」入口(T031);②未配置商家(NULL)展示为全不勾,改为默认全选展示对齐「NULL=接受全部」口径(T032);③银行名称下拉误调不存在的 `/system/bankList`,改调 `/infouser/user/bankList`(T033);④保存 SQL 写入 info_user 不存在的 update_time 列致 Unknown column,商家/骑手两处移除(T034)。同日补登记 031 全套 DDL 至 updatesql/sql.md(原误只写在 data-model.md 从未入库,已执行测试库并完成 027 存量卡迁移);清理 6 文件 17 处无效 `@Auth(session=true)` 标注(947867e)。部署链提醒:商家PC 测试站须以 .env.test 构建(曾误用生产 API 基址 api.awayqtw.com 导致 401 与行为漂移)。
+- **2026-10-10 骑手 LINE Pay**:PaymentMethodGateService 在 RIDER_FLASH 且 payeeUserId 已知时读取 IInfoRiderLinePayService.getEnabledCurrent;用户未确定骑手仍服从平台开关。待抢列表复用 listAvailable,接单新增同闸门 assertUsable,金额/收款人与凭证由 019 流程在服务端确定。新增凭证/SQL/UI由 019 管理,031 不新增字段或 App 改动,现金转账及商家就绪规则回归。

+ 3 - 0
specs/031-pay-method-config/spec.md

@@ -228,3 +228,6 @@
 平台在原支付方式矩阵增加「商家备注」输入框;每种方式对应 `MERCHANT` 配置行的 `remark`,不为骑手维度维护备注。商家支付设置页在方式选择区域下方按方式显示平台说明,普通商家与摊位主使用同一份平台文案,商家无编辑权限。
 
 验收:平台保存后重进回显;商家下次读取显示相同内容;换行和标点保留,HTML 作为普通文本;清空即隐藏;超过 500 字或给骑手维度提交非空备注时拒绝整批保存;不影响未就绪提示、勾选、用户结算和骑手支付方式接口。界面标题和提示提供四语言,管理员填写的备注按原文展示、不自动翻译,不硬编码费率及到账天数。迁移由开发者手动执行。
+## 2026-10-10 增量:闪送 LINE Pay 骑手凭证就绪
+
+RIDER_FLASH 仍取平台开关 ∩ 骑手 flashPayMethods。骑手未知(用户下单/报价)允许平台开放的 LINE 选项;骑手已知(待抢/直接接单)还必须有启用、已验证、当前环境一致的独立骑手凭证。缺失/禁用不允许抢该线上单,列表不是最终授权。现金/转账和商家维度不变,完整支付/退款规格见 019 rider-flash-online-spec.md。此节覆盖旧“骑手线上就绪度恒 true/手工确认线上收款”表述;不修改支付矩阵或 App。

+ 6 - 0
specs/031-pay-method-config/tasks.md

@@ -193,3 +193,9 @@ description: "Task list for 031-pay-method-config"
 - [x] T043 平台矩阵新增商家备注输入框、商家与摊位页面显示说明,补两端四语言标题/提示,保留原有换行风格。
 - [x] T044 更新现有规格、计划、数据模型、接口与手工迁移 SQL。
 - [x] T045 提交前后端全量回归 1001 项通过(新增备注测试共 12 项),支付相关定向测试及 JDK 21 模块打包通过;平台前端行为测试 13 项、商家前端 2 项通过,商家 `build:test` 成功,两端修改页面模板/脚本及四语言检查通过,三仓库 diff 已核对。平台完整构建未执行(未安装 node_modules);真实数据库迁移和登录浏览器冒烟未执行,由开发者部署前完成。
+## Phase 14:骑手 LINE Pay 就绪(2026-10-10,实现中)
+
+- [x] T046 已知骑手凭证就绪纳入现有闸门,未知骑手下单保持平台口径;直接接单再次复核,定向测试验证缺失/启用及转账/商家兼容。
+- [x] T047 联动 019/024 文档、最终全量测试与构建记录;不执行迁移、不修改 App。未提交/推送。
+
+Phase 14 本地验证:最终后端 1140 项测试、0 失败/错误/跳过,JDK 21 模块 package 成功;平台行为 16/16、build:stage 成功。骑手配置和闪送资金调用显式拒绝环境/网关错配;Secret 保存不进入浏览器重复提交缓存。真实迁移、MySQL 竞争、LINE 开通与支付退款及 App 联调未执行,完整证据见 019/tasks.md。

+ 58 - 0
updatesql/sql.md

@@ -1971,3 +1971,61 @@ SELECT 'stallQualificationReview', @stall_qualification_menu_id, 2, '', 1, 1,
   'F', '0', '0', 'stall:qualification:review', '#', 'admin', NOW()
 WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='stall:qualification:review');
 ```
+
+## 2026-10-10 019 骑手 LINE Pay 与闪送 Online(手动执行)
+
+不覆盖历史凭证,不回填历史线上收款事实;先执行迁移及配置 HTTPS 回跳,再部署代码。菜单权限由管理员分配;以下 SQL 本次不执行。
+
+```sql
+CREATE TABLE info_rider_line_pay (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ rider_id BIGINT NOT NULL, credential_version INT NOT NULL,
+ channel_id VARCHAR(100) NOT NULL, channel_secret VARCHAR(256) NOT NULL,
+ environment VARCHAR(16) NOT NULL, credential_status VARCHAR(24) NOT NULL,
+ is_enabled TINYINT NOT NULL DEFAULT 1, current_rider_id BIGINT NULL,
+ verify_return_code VARCHAR(8) NULL, verified_time DATETIME NULL,
+ create_time DATETIME NOT NULL, update_time DATETIME NOT NULL,
+ UNIQUE KEY uk_rider_current (current_rider_id),
+ UNIQUE KEY uk_rider_version (rider_id,credential_version)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='骑手 LINE Pay 不可变凭证版本';
+
+CREATE TABLE flash_delivery_line_payment (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ order_id BIGINT NOT NULL, rider_id BIGINT NOT NULL, credential_id BIGINT NOT NULL,
+ line_order_id VARCHAR(100) NOT NULL, transaction_id VARCHAR(64) NULL,
+ amount INT NOT NULL, captured_amount INT NULL, currency VARCHAR(3) NOT NULL,
+ payment_url VARCHAR(2000) NULL, status VARCHAR(32) NOT NULL,
+ active_order_id BIGINT NULL, version BIGINT NOT NULL DEFAULT 0,
+ lease_until DATETIME NULL, next_reconcile_at DATETIME NULL, reconcile_deadline DATETIME NULL,
+ paid_at DATETIME NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL,
+ UNIQUE KEY uk_flash_line_order (line_order_id), UNIQUE KEY uk_flash_line_tx (transaction_id),
+ UNIQUE KEY uk_flash_line_active (active_order_id),
+ KEY idx_flash_line_order_history (order_id,id), KEY idx_flash_line_reconcile (status,next_reconcile_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送骑手 LINE Pay 支付尝试';
+
+CREATE TABLE flash_delivery_line_refund (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, payment_id BIGINT NOT NULL,
+ amount INT NOT NULL, status VARCHAR(32) NOT NULL, refund_transaction_id VARCHAR(64) NULL,
+ version BIGINT NOT NULL DEFAULT 0, lease_until DATETIME NULL,
+ next_reconcile_at DATETIME NULL, reconcile_deadline DATETIME NULL,
+ refunded_at DATETIME NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL,
+ UNIQUE KEY uk_flash_line_refund_payment (payment_id), KEY idx_flash_line_refund_reconcile (status,next_reconcile_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送取消全额退款意图与事实';
+
+ALTER TABLE flash_delivery_order MODIFY COLUMN payment_status TINYINT NOT NULL DEFAULT 0
+ COMMENT '0未收款 1已收款(线上由服务端确认) 2线上已全额退款';
+
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
+SELECT 'riderPayment',0,19,'riderPayment','infouser/riderPayment/index',1,1,'C','0','0','infouser:riderLinePay:list','money','admin',NOW()
+WHERE NOT EXISTS(SELECT 1 FROM sys_menu WHERE perms='infouser:riderLinePay:list');
+SET @rider_line_pay_menu_id=(SELECT menu_id FROM sys_menu WHERE perms='infouser:riderLinePay:list' ORDER BY menu_id LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
+SELECT 'riderLinePayQuery',@rider_line_pay_menu_id,1,'',1,1,'F','0','0','infouser:riderLinePay:query','#','admin',NOW()
+WHERE NOT EXISTS(SELECT 1 FROM sys_menu WHERE perms='infouser:riderLinePay:query');
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
+SELECT 'riderLinePaySave',@rider_line_pay_menu_id,2,'',1,1,'F','0','0','infouser:riderLinePay:saveCredentials','#','admin',NOW()
+WHERE NOT EXISTS(SELECT 1 FROM sys_menu WHERE perms='infouser:riderLinePay:saveCredentials');
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time)
+SELECT 'riderLinePayToggle',@rider_line_pay_menu_id,3,'',1,1,'F','0','0','infouser:riderLinePay:toggleEnable','#','admin',NOW()
+WHERE NOT EXISTS(SELECT 1 FROM sys_menu WHERE perms='infouser:riderLinePay:toggleEnable');
+```