浏览代码

Merge branch 'test-202609v2'

# Conflicts:
#	.claude/homunculus/observations.jsonl
#	ruoyi-admin/src/main/resources/application.yml
#	ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
qmj 3 天之前
父节点
当前提交
2870545a02
共有 100 个文件被更改,包括 5706 次插入 和 1084 次删除
  1. 2 2
      .specify/feature.json
  2. 34 0
      .tmp/lanhu-flash-delivery/analysis.json
  3. 二进制
      .tmp/lanhu-flash-delivery/mcp-01.png
  4. 二进制
      .tmp/lanhu-flash-delivery/mcp-02.png
  5. 二进制
      .tmp/lanhu-flash-delivery/mcp-03.png
  6. 二进制
      .tmp/lanhu-flash-delivery/mcp-04.png
  7. 二进制
      .tmp/lanhu-flash-delivery/mcp-05.png
  8. 二进制
      .tmp/lanhu-flash-delivery/mcp-06.png
  9. 二进制
      .tmp/lanhu-flash-delivery/mcp-07.png
  10. 49 0
      .tmp/lanhu-mcp-read.mjs
  11. 21 0
      .tmp/lanhu-summarize.mjs
  12. 30 0
      .tmp/verify-flash-spec.mjs
  13. 1 0
      .video_agent/plugin_root
  14. 2 1
      AGENTS.md
  15. 5 4
      CLAUDE.md
  16. 116 23
      docs/flash-delivery-app-api.md
  17. 107 0
      docs/flash-delivery-line-pay-app-integration.md
  18. 150 0
      docs/flash-delivery-v2-frontend-handoff.md
  19. 58 0
      docs/map-api-usage.md
  20. 34 0
      docs/multi-store-test-hold.md
  21. 159 0
      docs/oauth-business-login-app-api.md
  22. 44 0
      docs/order-api-usage.md
  23. 7 5
      docs/order-push-overview.md
  24. 341 0
      docs/pay-method-config-app-api.md
  25. 40 0
      docs/platform-food-audit-api.md
  26. 221 0
      docs/stall-qualification-app-api.md
  27. 171 0
      docs/store-menu-copy-app-api.md
  28. 66 0
      docs/superpowers/plans/2026-09-17-flash-delivery-v1-v2-merge.md
  29. 38 0
      docs/twqr-offline-transfer-qr.md
  30. 90 0
      docs/user-push-voice-app-integration.md
  31. 64 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  32. 43 15
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  33. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java
  34. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java
  35. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  36. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  37. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  38. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPickupRequest.java
  39. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  40. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuantityDeserializer.java
  41. 12 4
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  42. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  43. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  44. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderBankCardView.java
  45. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderCountView.java
  46. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  47. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopDeliverRequest.java
  48. 31 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  49. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java
  50. 0 60
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  51. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressChangeRequest.java
  52. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressConfirmRequest.java
  53. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2CreateRequest.java
  54. 57 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2QuoteRequest.java
  55. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java
  56. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashLinePayCreateRequest.java
  57. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashLinePayPaymentView.java
  58. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayAsyncService.java
  59. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayController.java
  60. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayEvidence.java
  61. 175 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayFactService.java
  62. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayRefundService.java
  63. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayReturnController.java
  64. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayReturnPageRenderer.java
  65. 128 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/payment/FlashLinePayService.java
  66. 69 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProvider.java
  67. 62 25
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  68. 36 7
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  69. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  70. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteOutsideRegionException.java
  71. 85 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProvider.java
  72. 635 90
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  73. 99 9
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java
  74. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java
  75. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryUserCardStops.java
  76. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java
  77. 771 746
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  78. 152 13
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  79. 5 1
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEnrichService.java
  80. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/FoodAuditRequest.java
  81. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/SelfDeliveryConfigInput.java
  82. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketStoreController.java
  83. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  84. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java
  85. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java
  86. 27 9
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  87. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java
  88. 15 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java
  89. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java
  90. 295 21
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java
  91. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundTask.java
  92. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgRefundGateway.java
  93. 93 9
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  94. 14 13
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantNotificationRouter.java
  95. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderAppealController.java
  96. 25 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  97. 31 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java
  98. 60 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  99. 212 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutService.java
  100. 42 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutTask.java

+ 2 - 2
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/030-unified-order-list"
-}
+  "feature_directory": "specs/032-self-hosted-map-integration"
+}

文件差异内容过多而无法显示
+ 34 - 0
.tmp/lanhu-flash-delivery/analysis.json


二进制
.tmp/lanhu-flash-delivery/mcp-01.png


二进制
.tmp/lanhu-flash-delivery/mcp-02.png


二进制
.tmp/lanhu-flash-delivery/mcp-03.png


二进制
.tmp/lanhu-flash-delivery/mcp-04.png


二进制
.tmp/lanhu-flash-delivery/mcp-05.png


二进制
.tmp/lanhu-flash-delivery/mcp-06.png


二进制
.tmp/lanhu-flash-delivery/mcp-07.png


+ 49 - 0
.tmp/lanhu-mcp-read.mjs

@@ -0,0 +1,49 @@
+import { Client } from 'file:///C:/Users/qmj/AppData/Roaming/npm/node_modules/mcp-lanhu/node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.js';
+import { StdioClientTransport } from 'file:///C:/Users/qmj/AppData/Roaming/npm/node_modules/mcp-lanhu/node_modules/@modelcontextprotocol/sdk/dist/esm/client/stdio.js';
+import { mkdir, writeFile } from 'node:fs/promises';
+
+const projectUrl = 'https://lanhuapp.com/web/#/item/project/stage?tid=15517692-8265-47f1-9ba9-7616f57c770f&pid=498cd54f-39c7-4fcc-abf7-cb4e8fcb8f61&corpId=null';
+const transport = new StdioClientTransport({
+  command: 'C:\\Users\\qmj\\AppData\\Roaming\\npm\\mcp-lanhu.cmd',
+  args: [],
+  env: process.env
+});
+const client = new Client({ name: 'codex-lanhu-reader', version: '1.0.0' });
+await client.connect(transport);
+try {
+  const result = await client.callTool({
+    name: 'lanhu_design',
+    arguments: {
+      url: projectUrl,
+      mode: 'analyze',
+      design_names: ['1', '2', '3', '4', '5', '6', '7'],
+      include: ['image', 'tokens', 'layout', 'layers'],
+      layer_depth: 2
+    }
+  });
+  const payload = result.structuredContent ?? {};
+  const designs = Array.isArray(payload.designs) ? payload.designs : [];
+  const outDir = new URL('./lanhu-flash-delivery/', import.meta.url);
+  await mkdir(outDir, { recursive: true });
+  const images = result.content?.filter(item => item.type === 'image') ?? [];
+  for (let index = 0; index < images.length; index += 1) {
+    await writeFile(new URL(`mcp-${String(index + 1).padStart(2, '0')}.png`, outDir), Buffer.from(images[index].data, 'base64'));
+  }
+  await writeFile(new URL('analysis.json', outDir), JSON.stringify(payload, null, 2), 'utf8');
+  console.log(JSON.stringify({
+    isError: Boolean(result.isError),
+    projectName: payload.project_name ?? null,
+    status: payload.status ?? null,
+    totalDesigns: payload.total_designs ?? designs.length,
+    imageCount: images.length,
+    designs: designs.map(item => ({
+      name: item.name,
+      status: item.status,
+      outputs: item.outputs,
+      layerDepth: item.layer_depth,
+      layerTreeTruncated: item.layer_tree_truncated
+    }))
+  }));
+} finally {
+  await client.close();
+}

+ 21 - 0
.tmp/lanhu-summarize.mjs

@@ -0,0 +1,21 @@
+import { readFile } from 'node:fs/promises';
+
+const source = new URL('./lanhu-flash-delivery/analysis.json', import.meta.url);
+const payload = JSON.parse(await readFile(source, 'utf8'));
+
+for (const design of payload.designs ?? []) {
+  const annotations = design.sketch_annotations ?? '';
+  const textStart = annotations.indexOf('📝 文本图层:');
+  const shapeStart = annotations.indexOf('🔷 形状图层:');
+  const textSection = textStart >= 0
+    ? annotations.slice(textStart, shapeStart >= 0 ? shapeStart : undefined)
+    : '';
+  const texts = [...textSection.matchAll(/^  "(.+)"$/gm)].map((match) => match[1]);
+  console.log(JSON.stringify({
+    name: design.name,
+    status: design.status,
+    imageBytes: design.image_bytes,
+    layoutSource: design.layout_source,
+    texts
+  }));
+}

+ 30 - 0
.tmp/verify-flash-spec.mjs

@@ -0,0 +1,30 @@
+import fs from 'node:fs';
+
+const spec = fs.readFileSync('specs/024-flash-delivery/spec.md', 'utf8');
+const design = fs.readFileSync('specs/024-flash-delivery/design.md', 'utf8');
+const count = (value, pattern) => (value.match(pattern) ?? []).length;
+
+const result = {
+  stories: count(spec, /^### 用户故事/gm),
+  requirements: count(spec, /\*\*FR-\d{3}\*\*/g),
+  successCriteria: count(spec, /\*\*SC-\d{3}\*\*/g),
+  endpoints: count(design, /^- `(GET|POST|PUT|DELETE) /gm),
+  hasPaymentExclusion: spec.includes('不接入支付网关'),
+  hasAtomicAccept: spec.includes('原子条件更新'),
+  hasAddressOwnership: spec.includes('地址所有权校验'),
+  hasImageRequirement:
+    spec.includes('至少提供一张取件图片') &&
+    spec.includes('至少提供一张送达图片')
+};
+
+console.log(JSON.stringify(result));
+
+if (
+  result.stories !== 5 ||
+  result.requirements !== 39 ||
+  result.successCriteria !== 9 ||
+  result.endpoints !== 24 ||
+  Object.values(result).some((value) => value === false)
+) {
+  process.exit(1);
+}

+ 1 - 0
.video_agent/plugin_root

@@ -0,0 +1 @@
+C:\Users\qmj\.zcode\cli\plugins\cache\zcode-plugins-official\video-agent-kit\0.4.3

+ 2 - 1
AGENTS.md

@@ -10,6 +10,7 @@
 - 匹配现有代码风格。只移除本次修改造成的无用 import、变量或方法。
 - 修复缺陷时优先用可复现检查或测试证明问题,再验证修复结果;多步骤任务给出简短、可验证的执行计划。
 - 当用户说“只改 X”时,只改 X。简单的格式、显示或单文件修改不要创建任务计划,也不要启动 Agent。
+- Git 提交信息使用中文。这是用户明确要求长期保留的偏好,后续 commit/push 均遵守。
 
 ## OMG 支付执行纪律与本次复盘
 
@@ -66,7 +67,7 @@
 - `PosOrderShOprateController`:商家出餐和取消。
 - `PosOrderQsOprateController`:骑手接单、取餐、送达,使用 `deliveryStatus` 1/2/3。
 - `UserOrderController`:用户下单和取消。
-- `PosOrderController`:仅 `/addorder`、`/setorderuzt` 和 list 查询仍在使用,主要订单状态流转已经迁出。
+- `PosOrderController`:通用管理接口 `POST /system/order` 的 `add(...)`(新增订单)和 `PUT /system/order` 的 `edit(...)`(修改订单信息)已废弃;代码仅保留备查,不要作为订单创建或修改入口,也不要在其上做增量修改。`/addorder`、`/setorderuzt` 和 list 查询仍在使用,主要订单状态流转已经迁出。
 
 推送以当前真实通道为准:`PayPush` 没有 Android FCM;本项目仅有 iOS uni 云函数 `msduser`、`msdrider`、`msdstore`,以及 `PushEvent -> push_message` 入库。`cidType` 当前没有实际区分作用。相关状态见 `specs/012-im-user-integration/code-status.md`。
 

+ 5 - 4
CLAUDE.md

@@ -1,6 +1,6 @@
-# foodie_server Development Guidelines
+# foodie_server Development Guidelines
 
-Auto-generated from all feature plans. Last updated: 2026-09-20
+Auto-generated from all feature plans. Last updated: 2026-08-18
 
 ## Tech Stack
 
@@ -30,12 +30,13 @@ Auto-generated from all feature plans. Last updated: 2026-09-20
 | `PayController.java` | VNPay 回调及多数方法废弃 | **特例**:`sendAcceptRiderPush(...)` 仍被 `PosOrderController` 货到付款路径引用;迁移推迟到接入新支付时 |
 | `TestTask.java` | 定时任务全部废弃 | 含 refundProcessing / 抽成返还 / 自动开店 / 超时退款;相关推送当前均不存在 |
 | `OrderAppealController.java` | 取消/申诉整片注释 | 仅留 `parseLocale`;取消/申诉推送全部失效 |
+| `VipQuanyiController.java` / `VipSjQuanyiController.java` / `SalesPromotionController.java` | 旧促销/优惠券系统废弃(spec 008,2026-10-13 确认) | 已被 `promotion_*` 新表/新接口替代;平台端菜单已隐藏(sys_menu 2003/2020/2118/2179,SQL 见 updatesql/sql.md);同属废弃的旧文件还有 `VipQuanyi`、`VipUserQuanyi`、`ShSalesPromotionController`,均不要参考或增量修改 |
 
 **当前在用的订单操作入口(状态流转/推送以此为准):**
 - `PosOrderShOprateController` — 商家操作(出餐 chuCan、商家取消)
 - `PosOrderQsOprateController` — 骑手操作(接单/取餐/送达,按 `deliveryStatus` 1/2/3)
 - `UserOrderController` — 用户操作(下单、用户取消)
-- `PosOrderController` — 仅 `/addorder`(下单,含货到付款 sendHdfkMessage)、`/setorderuzt`(平台改状态)及 list 查询在用;订单状态流转已迁出
+- `PosOrderController` — 通用管理接口 `POST /system/order` 的 `add(...)`、`PUT /system/order` 的 `edit(...)` 及 `/addorder`(旧代客外送下单,货到付款)均已废弃(2026-09-29 确认),代码仅保留备查,不要作为业务入口或在其上增量修改;商家端下单走 `PosOrderShOprateController` 的 `/createOrder`;`/setorderuzt`(平台改状态)及 list 查询仍在用;订单状态流转已迁出。详见 docs/order-api-usage.md
 
 **推送通道:** `PayPush` 无 Android FCM(cte_server 有,本项目砍掉),仅 iOS uni 云函数(`msduser/msdrider/msdstore`)+ 入库(`PushEvent`→`push_message`);`cidType` 形同虚设(到处传 `""`)。详见 `specs/012-im-user-integration/code-status.md`。
 
@@ -204,5 +205,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/030-unified-order-list/plan.md`
+shell commands, and other important information, read the current plan: `specs/031-pay-method-config/plan.md`
 <!-- SPECKIT END -->

+ 116 - 23
docs/flash-delivery-app-api.md

@@ -2,7 +2,7 @@
 
 本文档供用户端 App 和骑手端 App 接入闪送功能使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。
 
-> 更新时间:2026-09-09。新增待接单订单修改发件/收件信息与追加小费:修改地址先重报价,用户确认后保存;保存时校验订单状态和版本。闪送仍不接入支付。
+> 更新时间:2026-09-30。本次增加闪送完成后的骑手收入账单;款项仍直付骑手,不入平台钱包。此前待接单地址重报价、确认保存与追加小费契约保持不变;线下支付及收款确认参照 031 文档。
 
 当前下单与订单编辑契约要点:
 
@@ -29,7 +29,7 @@
 - 寄件、取件和送达图片凭证。
 - 送达 24 小时后仍未由用户确认的订单自动完成。
 
-当前不包含支付、退款、骑手收入、结算、代购垫付、自动派单和骑手放弃订单。
+当前不包含支付网关、退款、平台结算、钱包入账、代购垫付、自动派单和骑手放弃订单。2026-09-30 增量增加完成后的骑手收入账单,款项直付骑手、不通过平台提现,接入说明见文末。
 
 设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。发布成功页统一使用静态文案“发布后等待附近骑手接单”。
 
@@ -673,25 +673,9 @@ POST /system/flashDelivery/orders
 
 创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
 
-### 7.4 查询用户订单列表
+### 7.4 查询用户订单列表(已删除,改用统一订单列表)
 
-```http
-GET /system/flashDelivery/orders?page=1&size=10&role=sender
-```
-
-查询参数:
-
-| 参数 | 必填 | 说明 |
-|---|---:|---|
-| `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
-| `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
-| `role` | 否 | `sender` 查询“我发的”,`receiver` 查询“我收的”;默认 `sender` |
-
-仅查询当前用户作为指定角色的订单,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
-
-`role` 只接受 `sender` 或 `receiver`,不区分大小写;传空值按 `sender` 处理,传其他值返回业务错误。“我收的”依据订单当前绑定的收件账号;创建时匹配,确认修改取送信息时重新匹配收件电话,查询时不会重新按手机号匹配。
-
-设计原型中的“待接单、配送中、已完成、已取消”筛选页签当前没有对应查询参数。第一版建议只展示“全部”列表;如需页签,只能基于当前页数据本地过滤,且不应依赖它得到准确的分页总数。
+`GET /system/flashDelivery/orders` 已于 2026-10-15 删除,用户端订单列表统一走 `GET /system/unifiedOrder/list`(外卖与闪送合并,游标分页)。原 `role=sender|receiver` 语义由统一列表的 `role` 字段承接:寄件人条目 `role=sender`,收件人条目 `role=receiver`;收件人条目的 `deliveryAddress` 为本人站点地址,多站点单附 `stops` 逐站列表与双进度计数。
 
 ### 7.5 查询用户订单详情
 
@@ -699,7 +683,7 @@ GET /system/flashDelivery/orders?page=1&size=10&role=sender
 GET /system/flashDelivery/orders/{id}
 ```
 
-订单发件人和当前绑定的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
+订单发件人和当前绑定的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。订单视图含 `payType` 与 `paymentStatus`(`0` 未确认收款、`1` 骑手已确认收款);线下转账单(`payType=6`)送达后寄件人详情额外返回 `riderBankCard`。本节原“响应不包含支付状态或支付方式字段”的说明自 2026-09-29(031 支付方式配置)起作废,接入以 [支付方式配置 App 接入文档](pay-method-config-app-api.md) 为准。
 
 ### 7.6 用户取消订单
 
@@ -933,12 +917,15 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
   "tipAmount": 5,
   "amount": 92,
   "currency": "TWD",
-  "createTime": "2026-09-01T09:00:00+08:00"
+  "createTime": "2026-09-01T09:00:00+08:00",
+  "userNote": null
 }
 ```
 
 接单前列表可展示完整的取件、送达文字地址(包括详细地址),但不得返回联系人、电话、实际 PIN、精确坐标、用户备注、图片凭证、状态日志、用户 ID、幂等号或计价审计字段。列表中的文字地址不能作为精确坐标使用。
 
+`userNote` 为用户备注:骑手本人任务页签(`toPickup`、`delivering`、`completed`、`cancelled`)返回订单备注,骑手无需进详情即可查看;`newTask` 待抢单卡片按脱敏规则固定为 `null`。
+
 ### 8.2 查询骑手订单详情
 
 ```http
@@ -953,7 +940,11 @@ GET /system/flashDelivery/rider/orders/{id}
 POST /system/flashDelivery/rider/orders/{id}/accept
 ```
 
-无请求体。骑手账号必须已开通闪送配送。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。违反急送独占规则时返回统一的独占冲突提示,App 应保留在列表页并刷新可接任务。
+无请求体。服务端取得骑手锁后重新校验,不信任待抢列表的过滤结果。骑手必须已审核、账号启用、`userType=2`、`offline=0` 并开通闪送配送;同时校验进行中闪送容量、外卖与闪送合计容量及急送独占。容量分别由 `sys_rider_flash_active_order_limit`、`sys_rider_total_active_order_limit` 控制,外卖接单另使用 `sys_rider_food_active_order_limit`;配置只接受 1..20,缺失、空值、非法、越界或读取异常一律按 1 执行,不会变成不限单。
+
+抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段。失败会返回明确原因,包括账号未审核或停用、骑手离线、未开通闪送、闪送任务已满、配送任务总量已满、急送独占冲突或订单已被其他骑手接走;App 应展示 `msg`、保留在列表页并刷新可接任务。
+
+当前版本尚未把容量规则用于待抢列表,也未在接单时校验服务端位置更新时间或接单距离,因此列表可见不代表一定能接;最终以本接口复核为准。
 
 ### 8.4 确认取件
 
@@ -1125,3 +1116,105 @@ POST /system/address/{id}/top
 - “我收的”按订单当前绑定的账号展示;创建或确认修改取送信息时匹配收件电话,清空或更换绑定后旧收件人不再有访问权;查询时不动态认领历史订单。
 - 发布成功页显示“发布后等待附近骑手接单”,不显示虚构的骑手数量或预计接单时长。
 - 骑手端金额文案统一为“订单金额”,不使用“收益”;联系骑手/用户通过 IM,不提供通话。
+
+## 2026-09-15:多取货点客户端接入
+
+本节覆盖上文单点输入、订单级取件和仅按主状态显示取消按钮的旧说明。完整字段与异常规则见 [API 契约多取货点增量](../specs/024-flash-delivery/contracts/api.md#2026-09-15-多取货点增量覆盖上文单点口径)。
+
+### 下单与编辑
+
+首页读取 `pickupStopLimit` 控制取货点数量;兼容字段 `showMultiPickup` 由上限是否大于 1 派生,`showUrgentOption` 固定为 true,加急始终展示。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
+
+```json
+{
+  "serviceType": "HELP_SEND", "deliveryType": "NORMAL", "deliveryMode": "NOW", "tipAmount": 0,
+  "pickups": [
+    {"name":"寄件人 A","phone":"0912345678","address":"台北市信义区 A 点","latitude":25.033,"longitude":121.5645,"packageType":"DOCUMENT","quantity":1,"weightRange":"UP_TO_5_KG"},
+    {"name":"寄件人 B","phone":"0922345678","address":"台北市大安区 B 点","latitude":25.026,"longitude":121.543,"packageType":"GIFT","quantity":2,"weightRange":"OVER_5_TO_10_KG"}
+  ],
+  "delivery":{"name":"收件人","phone":"0932345678","address":"台北市中山区收件点","latitude":25.052,"longitude":121.531}
+}
+```
+
+创建时再附加既有幂等号、报价版本和金额回传字段;待接单编辑使用完整数组,调整顺序同样先重报价并确认。下单页面应说明客户顺序用于报价和展示,骑手可自行决定实际访问顺序;移除“骑手如需调整将先与您沟通”的旧提示。用户原顺序及金额不会因骑手调整访问先后而改变。
+
+### 骑手自主选择站点并确认取件
+
+1. 详情读取 `stops[]`,骑手自主选择待完成站;站点 `id` 是服务端 ID,不是数组下标。客户顺序仅用于报价与展示。
+2. 实际到站取到货后,直接调用 `POST /rider/orders/{id}/stops/{stopId}/pickup` 提交本站 `imageUrls`;也可使用原 `/pickup`,多点必须提供 body `stopId`,单点可省略。无需先调用目标接口、填写原因或声明客户同意。
+3. `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` 已过期,仅兼容旧客户端保存可选导航目标;新 App 移除“目前目标”和“设为目前目标”。站点不能删除,也不能用虚假取件跳过;最终无法取货须联系平台处理。
+
+### 进度、取消与导航
+
+各端显示 `pickedUpStopCount/pickupStopCount`,保留原站点顺序展示实际进度;部分已取仍为 `ACCEPTED`,用户取消按钮使用 `canUserCancel` 且结合当前用户角色。任意取件、取消、调整或刷新后重新读取最新详情,不能仅按主状态判断按钮。
+
+导航目标由骑手在 `stops[]` 中自行选择。客户端生成 Google Maps URL,使用 `api=1&travelmode=two-wheeler&dir_action=navigate`,地址参数正确编码,起点取设备当前位置。没有定位或无法启动外部地图时,保留目标地址并允许重试;不得因此自动取件。后端不返回导航 URL。
+
+上线前用户、骑手端必须完成数组、逐站凭证及状态按钮适配,再调高多点数量上限。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。
+
+## 2026-09-30:闪送完成后的骑手收入账单
+
+用户签收、平台完成或 24 小时自动完成成功后,服务端生成一条骑手账单,收入为订单 `amount`(含配送费、加急费、小费);不扣外卖抽成/税,不增加钱包余额,不走平台提现。不等骑手确认收款才生成账单,也不会自动将 `paymentStatus` 改为 1。“确认收款”接口保持仅记录实际收款声明,不额外生成账单。历史已完成订单不自动补账。
+
+使用现有 `/Billing/billing/getzddaylist`、`getzdmeeklist`、`getzdmatlist` 查询,日/周/月总收入和完成单数包含闪送及外卖。列表增加 `orderSource`:`FLASH` 为闪送、`FOOD` 为外卖(历史空来源的骑手订单账单按外卖兼容)。原列表字段保持兼容,闪送另有:
+
+| 字段 | 含义 |
+| --- | --- |
+| `ddId` | 闪送展示订单号,不是外卖订单 ID |
+| `flashOrderId` | 闪送主键,用于 `/system/flashDelivery/rider/orders/{id}` 详情 |
+| `amount` | 全额收入,已经包含小费 |
+| `freight` / `tipAmount` | 不含小费的配送费(含加急费) / 小费;二者相加为 amount |
+| `jvli` / `qsStarts` | 路线公里距离 / 当前骑手评分,未评分可为空 |
+| `payType` / `paymentStatus` | 4 现金、6 线下转账 / 0 未确认收款、1 已确认 |
+
+App 应根据来源打开对应订单详情,不能将账单收入展示为平台可提现金额,也不能将有账单标记为已收款。已有“未接单报价不得展示为收益”的限制不变;本节只放开已完成订单的收入账单。此次没有修改 App 页面,真实 App 展示需客户端接入验证。部署前执行 `updatesql/sql.md` 中本次 `user_billing.order_source` 增量 SQL。
+
+## 2026-10-14 新多点取送接口的参数位置
+
+本次新增接口带 body 时,业务参数统一放 body,token 保持请求头;GET 简单参数和已有旧接口保持原样。v2 报价/创建继续使用 `/system/flashDelivery/v2/quote` 与 `/system/flashDelivery/v2/orders`。
+
+新逐站送达采用固定路径:
+
+```http
+POST /system/flashDelivery/rider/orders/stops/deliver
+```
+
+```json
+{
+  "orderId": 10,
+  "stopId": 13,
+  "pinCode": "4821",
+  "imageUrls": ["/profile/delivery.jpg"]
+}
+```
+
+订单、站点 ID 必须为正整数;`stopId` 从订单详情 `stops` 中对应 DELIVERY 站的 `id` 取得,单收货站也必须传。照片至少一张;订单启用 PIN 时提交对应收货站的码,未启用时可省略 `pinCode`。服务器核实站点归属和骑手权限;全部收货站送达后才推进整单状态。
+
+此前当日新增的带路径 ID 逐站送达地址不再作为新接口契约。已有 LINE Pay、旧取件/送达/目标调整/改址及平台接口未调整,仍按原文档接入。
+
+## 2026-10-14 多点履约与改址修复
+
+v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达可直接指定任意符合条件的待送站,无需先通过目标调整入口选站。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
+
+`stops[].id` 是站点 ID。启用 PIN 时,v2 详情在对应收货站的 `deliveryPinCode` 返回各站独立收货码;发起人可见全部,系统内匹配的收件人仅能看到自己的站点收货码,骑手看不到。收件人卡片主位的收货地址指向本人站点;其他收货站的文字地址与备注对寄件、收件同口径返回(2026-10-15 放开),联系人和坐标仍隐藏。匹配不到系统用户时,仍保留联系人电话和按站收货码,由发起人线下告知;本阶段不实现逐站收件人推送。v2 订单级 `deliveryPinCode` 不再用作送达码。订单列表和详情提供 `deliveryStopCount`、`deliveredStopCount` 供展示进度。
+
+v2 待接单订单修改站点使用两个新接口,token 在请求头,其余业务参数全部在 JSON body:
+
+```http
+POST /system/flashDelivery/v2/orders/address/quote
+POST /system/flashDelivery/v2/orders/address
+```
+
+报价 body 为 `{"orderId":10,"orderVersion":0,"stops":[...]}`,其中 `stops` 必须是完整的取货与收货站数组,结构与 v2 创建相同。确认保存 body 再附上报价返回的 `quotedDistanceMeters`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount`。服务端沿用该订单的物品和运价快照,并核对版本与报价;保存成功后按返回详情刷新全部 `stops[].id` 和收货码。旧改址入口遇到 v2 订单会拒绝,旧订单的参数和行为保持原样。
+
+每站取货或送达首次成功提交后,发起人收到一次进度推送及消息中心记录。payload 的 `eventType` 为 `FLASH_PICKUP_STOP_COMPLETED` 或 `FLASH_DELIVERY_STOP_COMPLETED`,并含 `ddId`、真实订单 `state`、`stopId`、`stopType`、从 0 开始的 `stopOrder`、`completedStopCount`、`totalStopCount`。同站重试不会重复通知,最后一站不再叠发旧整单阶段通知。App 按 `ddId` 打开订单详情,以最新详情展示站点进度。逐站收件人通知记录为后续需求,本轮暂缓。
+
+## 2026-10-15 v2 交错取送与双进度(Tab 不变)
+
+v2 报价、创建和待接单完整改址的 `stops[]` 按客户顺序提交:首站取件、末站收货,中间允许交错,例如取1→取2→送1→取3→送2。不得自动按类型分组;地图报价使用完整原顺序。各站备注由客户说明物品,骑手负责核对,不新增取送关联字段。简明接入见 [v2 前端交接文档](flash-delivery-v2-frontend-handoff.md)。
+
+骑手详情的 `currentTargetStopId` 仅是兼容旧客户端的导航提示;新旧订单均可直接按任意待完成站 `stopId` 确认,无需先设目标。新 App 不再显示“目前目标”或“设为目前目标”,目标接口标记过期。取送仍须上传本站凭证,送达时核验本站 PIN;尚未取过货不能送,漏取时不能确认最后一个未完成收货站。旧契约订单只有一个收货站,须先完成所有取货站。实际取送不修改原站序或运费。
+
+五个 Tab 与名称保持:`newTask` 新任务、`toPickup` 待取件、`delivering` 配送中、`completed` 已送达、`cancelled` 已取消。未全部取完仍为 ACCEPTED,即使部分站点已送达;全部取完才为 PICKED_UP,全部取送站完成才为 DELIVERED。每笔订单只计一个 Tab,角标继续按订单数。
+
+列表和详情同时展示两组已有字段:`已取件 {pickedUpStopCount}/{pickupStopCount} · 已送达 {deliveredStopCount}/{deliveryStopCount}`。例如已取2/3、已送1/2仍在待取件;取完后3/3、1/2进入配送中;全部送达3/3、2/2进入已送达。不要用主状态覆盖双进度,逐站推送中的 `state` 也可能仍为 ACCEPTED。本轮仅提供接口及文档,App 源码由前端人员接入,无新 SQL。

+ 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 多连接测试。未提交/推送。

+ 150 - 0
docs/flash-delivery-v2-frontend-handoff.md

@@ -0,0 +1,150 @@
+# 闪送 v2 多点取送前端对接说明
+
+更新时间:2026-10-15。本文对应当前仓库中的后端实现,供用户端和骑手端接入;联调前需确认服务端部署版本和 [10 月 14 日数据库执行说明](../updatesql/sql.md)。10 月 15 日交错取送与进度调整无新增字段、无新增 SQL。
+
+## 本次要调整的页面和流程
+
+| 页面/场景 | 前端调整 |
+| --- | --- |
+| 用户填写闪送订单 | 按客户顺序编辑一个 `stops` 数组,首站取货、末站收货,中间可以交错取送;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
+| 报价与下单 | 使用 `/v2/quote` 和 `/v2/orders`。创建时把本次报价的配置 ID、版本和费用原样回传;创建请求号在重试时保持一致。 |
+| 用户/收件人订单列表和详情 | 展示取货、收货两个进度;站点使用 `stops[].id`,不可把数组下标当成站点 ID。逐站收货码按权限展示。 |
+| 骑手履约 | 保留原五个 Tab 和名称,卡片增加取件、送达双进度。直接从 `stops[]` 选择待完成站,移除“目前目标/设为目前目标”;每站上传凭证并刷新详情。逐站送达使用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
+| 待接单改址 | v2 使用新的完整站点报价与保存接口。保存成功后刷新详情,替换本地旧站点 ID 和收货码。 |
+| 发起人消息 | 识别两种逐站进度事件,凭 `ddId` 打开订单详情,再读取最新状态。逐站收件人通知本期不接入。 |
+
+所有下列新 POST 接口使用 `token` 请求头和 JSON body。只有**新接口**采用“业务参数统一放 body”的约定;现有 GET、旧取件、旧目标调整、旧订单接口及 LINE Pay 接口的参数格式保持原样。响应仍为项目通用 `AjaxResult`,业务数据在 `data` 中;订单详情的 `data` 直接是订单对象,不再套一层 `order`。
+
+## 用户端:填写、报价、创建
+
+先调用 `GET /system/flashDelivery/home`,使用 `data.pickupStopLimit`、`data.deliveryStopLimit` 控制添加站点按钮和提交数量。有效范围为 1–10,默认均为 1;对应上限大于 1 即可使用多站点,无需开关。兼容字段 `showMultiPickup`、`showMultiDropoff` 分别表示对应上限是否大于 1,`showUrgentOption` 始终为 true;加急选项直接展示。服务类型、配送等级和可用支付方式也从首页响应读取。
+
+`POST /system/flashDelivery/v2/quote` 的示例 body:
+
+```json
+{
+  "serviceType": "HELP_SEND",
+  "deliveryType": "NORMAL",
+  "vehicleType": 1,
+  "packageType": "DOCUMENT",
+  "quantity": 2,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "两个文件袋",
+  "tipAmount": 0,
+  "deliveryMode": "NOW",
+  "payType": "4",
+  "stops": [
+    {"type":"PICKUP","name":"取件人甲","phone":"0911000001","address":"台北市信义区甲路1号","addressDetail":"1楼","longitude":121.5601,"latitude":25.0301,"remark":"取文件袋甲"},
+    {"type":"PICKUP","name":"取件人乙","phone":"0911000002","address":"台北市信义区乙路2号","longitude":121.5602,"latitude":25.0302,"remark":"取文件袋乙"},
+    {"type":"DELIVERY","name":"收件人丙","phone":"0911000003","address":"台北市大安区丙路3号","longitude":121.5603,"latitude":25.0303,"remark":"交给本人"},
+    {"type":"PICKUP","name":"取件人戊","phone":"0911000005","address":"台北市大安区戊路5号","longitude":121.5605,"latitude":25.0305,"remark":"取给丁的文件"},
+    {"type":"DELIVERY","name":"收件人丁","phone":"0911000004","address":"台北市大安区丁路4号","longitude":121.5604,"latitude":25.0304,"remark":"交前台"}
+  ]
+}
+```
+
+至少一个 `PICKUP` 和一个 `DELIVERY`,首站取货、末站收货,中间允许交错;默认路线及报价顺序就是数组顺序,不要把站点按取/送重新分组。同一地址或坐标不能重复。每站分别填写姓名、电话、完整地址和经纬度,可选门牌、交接方式、最长 200 字备注。客户在备注写清本站取什么/送什么,骑手负责核对,不需要提交取货站与收货站关联关系。v2 站点不要提交旧结构中的逐站物品字段;`packageType`、正整数 `quantity`、可选 `weightRange` 和 `specification` 只在订单级出现一次。`SCHEDULED` 订单还需提交 `scheduledPickupStartAt`、`scheduledPickupEndAt`,`NOW` 订单无需提交这两个字段。
+
+创建订单使用 `POST /system/flashDelivery/v2/orders`。body 包含报价时的业务字段和完整 `stops`,再增加:
+
+```json
+{
+  "clientRequestId": "客户端生成并持久保存的本次下单请求号",
+  "pricingId": 123,
+  "pricingVersion": 1,
+  "quotedBaseDeliveryFee": 80,
+  "quotedDistanceFee": 10,
+  "quotedUrgentFee": 0,
+  "quotedAmount": 90,
+  "pinRequired": true,
+  "senderImageUrls": [],
+  "userNote": "请注意文件袋"
+}
+```
+
+上述价格数字只展示字段形状,实际值必须取本次报价 `data` 的 `pricingId`、`pricingVersion`、`baseDeliveryFee`、`distanceFee`、`urgentFee`、`amount`,不可使用示例数字。`clientRequestId` 最长 64 字符;同一次创建的网络重试使用相同值,新订单使用新值。`pinRequired` 省略时默认为 `true`,开启后每个收货站有独立四位收货码。报价在创建前变化时,接口会返回错误并在 `data` 附最新报价;展示新金额让用户重新确认后再提交。
+
+## 订单详情、收件人和进度
+
+- 用户列表:统一订单列表 `GET /system/unifiedOrder/list`(2026-10-15 起旧闪送专属列表 `GET /system/flashDelivery/orders` 已删除);`receiver` 角色条目包括每一个已匹配到系统普通用户的收货站。收件人列表卡片的 `deliveryAddress` 指向本人站点;`stops` 全站文字地址与备注对寄件、收件同口径返回,联系人、电话、坐标不落卡片。
+- 用户详情:`GET /system/flashDelivery/orders/{id}`;按订单号打开消息:`GET /system/flashDelivery/orders/byOrderNo?orderNo=...`。
+- 骑手列表与详情:`GET /system/flashDelivery/rider/orders`、`GET /system/flashDelivery/rider/orders/{id}`,沿用原有 `tab`、分页和可选坐标参数。
+
+详情 `data.stops` 按客户原路线顺序返回。请求站点字段叫 `type`,响应字段叫 `stopType`;响应的 `id` 才是后续履约所需的 `stopId`,`stopOrder` 从 0 开始。每站有 `status`、`remark`、`pickedUpAt`/`deliveredAt` 和 `imageUrls`。列表与详情提供 `pickupStopCount`、`pickedUpStopCount`、`deliveryStopCount`、`deliveredStopCount`。未全部取完时整单仍为 `ACCEPTED`,即使有站点已送达;全部取完为 `PICKED_UP`;全部取送站完成为 `DELIVERED`;确认收货或平台/系统完成后为 `COMPLETED`。页面不要只靠整单 `status` 推算逐站进度。
+
+### 骑手 Tab 保持原样,只增加进度
+
+| 原 Tab(参数不变) | 订单归属 |
+| --- | --- |
+| 新任务 `newTask` | `WAITING_ACCEPTANCE`,沿用原有可抢范围规则 |
+| 待取件 `toPickup` | `ACCEPTED`,还有取货站未完成;可以已有部分送达 |
+| 配送中 `delivering` | `PICKED_UP`,已全部取货,还有收货站未完成 |
+| 已送达 `completed` | `DELIVERED` / `COMPLETED` |
+| 已取消 `cancelled` | `CANCELLED` |
+
+卡片及详情同时显示 `已取件 {pickedUpStopCount}/{pickupStopCount} · 已送达 {deliveredStopCount}/{deliveryStopCount}`,文本按 App 四语言规范接入。例如取1→取2→送1→取3→送2:送1后显示“已取件 2/3 · 已送达 1/2”,仍在待取件;取3后显示“已取件 3/3 · 已送达 1/2”,进入配送中;送2后进入已送达。不要把它拆成两条订单或同时放入两个 Tab,角标计订单数量而非站点数,继续使用原列表及 `GET /system/flashDelivery/rider/orderCount`。列表展示进度;进入详情后使用 `currentTargetStopId` 突出下一站的取/送类型。
+
+v2 的订单级 `deliveryPinCode` 为空,收货码在各个 DELIVERY 站的 `stops[].deliveryPinCode`。发起人可查看全部收货码;已在系统内匹配的收件人只能查看自己站的收货码、联系人、坐标和凭证,其他站的文字地址与备注同口径开放(2026-10-15);骑手看不到任何实际收货码。未匹配系统用户的收货站仍可下单,但此人不会进入“我收的”列表,也不会收到 App 推送;发起人需自行告知该站收货码。**逐站收件人通知本期暂缓。**
+
+用户端取消按钮以 `canUserCancel` 为准;任一取货站已取货后不可自助取消。确认收货入口只在整单 `DELIVERED` 后提供;系统仍会检查全部收货站是否真的已送达。骑手收入账单只在订单进入 `COMPLETED` 时生成一次。
+
+## 骑手端:自由取货、送达
+
+骑手直接从详情 `stops[]` 选择待完成取货/收货站,使用该站 `id` 确认;无需先设为当前目标。新旧订单都不限制取货站顺序,v2 收货站也可自由选择,但未取过任何货不能送达,仍有待取货站时不能确认最后一个收货站。客户原站序仅用于展示和报价,实际取送不重排站点或重算运费。App 移除“目前目标”标识和“设为目前目标”操作;操作成功后重新请求详情。
+
+| 操作 | 当前接口 | 提交内容 |
+| --- | --- | --- |
+| 逐站取货 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup` | 现有接口,body 为 `{"imageUrls":["/profile/pickup.jpg"]}`;如同时传 `stopId`,必须与路径一致。 |
+| 选择目标站 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | **已过期,仅兼容旧客户端**;新客户端不要调用。旧客户端调用时 body 含 `orderVersion`、`clientRequestId`,`reason`(≤500字)和 `consentDeclared` 可选。 |
+| 逐站送达 | `POST /system/flashDelivery/rider/orders/stops/deliver` | **新接口**,body 含 `orderId`、`stopId`、`imageUrls`,启用 PIN 时再传该站 `pinCode`。 |
+
+取货和送达凭证每站至少 1 张、最多 9 张。新送达接口中 `orderId`、`stopId` 均为正整数;**只有一个收货站的 v2 订单也必须传 `stopId`**。骑手在送达现场向收件人核对其本站收货码,不能使用别站或订单级收货码。新骑手端可统一根据详情中的站点 ID 使用逐站送达接口;旧 `/rider/orders/{id}/deliver` 只供旧客户端处理旧订单,v2 订单调用它会被拒绝。
+
+取送任何待完成站都直接提交该站 ID,无需目标调整记录或客户同意声明;实际物品由骑手根据备注核对。旧契约订单只有一个收货站,必须先完成所有取货站,避免漏取提前完单。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
+
+## 用户端:v2 待接单改址
+
+仅待接单、未分配骑手的 v2 订单可编辑。先从详情读取 `orderVersion` 和完整站点,编辑后提交所有取货与收货站;只提交被修改的一站会丢失其余站点,因此服务端要求完整数组。
+
+```http
+POST /system/flashDelivery/v2/orders/address/quote
+token: <登录 token>
+Content-Type: application/json
+```
+
+```json
+{"orderId":10,"orderVersion":0,"stops":[{"type":"PICKUP","name":"...","phone":"...","address":"...","longitude":121.56,"latitude":25.03},{"type":"DELIVERY","name":"...","phone":"...","address":"...","longitude":121.57,"latitude":25.04}]}
+```
+
+确认保存调用 `POST /system/flashDelivery/v2/orders/address`,body 保留相同的 `orderId`、`orderVersion`、**完整** `stops`,再回传报价 `data` 中的 `distanceMeters`→`quotedDistanceMeters`、`baseDeliveryFee`→`quotedBaseDeliveryFee`、`distanceFee`→`quotedDistanceFee`、`urgentFee`→`quotedUrgentFee`、`amount`→`quotedAmount`。只改站点,物品、配送方式、小费和原运价快照继续沿用订单保存值。报价或版本变化时重新获取并让用户确认。保存成功后以返回详情更新全部 `stops[].id` 和各站收货码;旧站点 ID 已失效。旧改址入口会拒绝 v2 订单,旧订单仍沿用原入口。
+
+## 发起人逐站通知
+
+每个取货/收货站首次成功且事务提交后,服务端向**订单发起人**推送并写入消息中心。两种 `eventType`:`FLASH_PICKUP_STOP_COMPLETED`、`FLASH_DELIVERY_STOP_COMPLETED`。消息 payload 示例:
+
+```json
+{
+  "ddId": "订单号",
+  "state": "PICKED_UP",
+  "type": -1,
+  "pushType": 3,
+  "eventType": "FLASH_PICKUP_STOP_COMPLETED",
+  "stopId": 13,
+  "stopType": "PICKUP",
+  "stopOrder": 0,
+  "completedStopCount": 2,
+  "totalStopCount": 2
+}
+```
+
+`state` 是通知发生时真实的整单状态,不应写死为示例值;`stopOrder` 为路线数组的 0 基下标,通知文案的站号为 1 基。收到事件后按 `ddId` 请求 `/orders/byOrderNo` 并刷新详情;不要从消息体推断联系人、地址或 PIN。相同站点的重试不重复通知,最后一站也不叠发旧整单阶段通知。逐站收件人通知留待后续需求。
+
+## 联调验收重点
+
+1. 单取单送和多取多送都走 v2 报价/创建;收货站只有一站时,送达仍提供 `stopId`。
+2. 验证取1→取2→送1→取3→送2,以及骑手自主改为先取完再送;送1后 `ACCEPTED`、进度 2/3 与 1/2,取3后 `PICKED_UP`,送2后 `DELIVERED`。五个 Tab 和名称不变,不能提前确认完成或记账。
+3. 修改第二个收货站后,其他站、订单级物品和原运价仍在;保存后旧站点 ID/PIN 不再使用。
+4. 发起人看全部收货码,匹配的第二站收件人看得到全部站的文字地址与备注,但联系人、坐标、凭证和 PIN 只有自己站可见;骑手看不到 PIN;未匹配用户不出现 App 收件人入口。
+5. 发起人收到每站一次进度消息并能按订单号打开详情;收件人没有逐站推送。真实设备推送和 App 页面尚需联调。
+
+现有单点、支付及其他旧接口以 [原闪送 App API 文档](flash-delivery-app-api.md) 为准。数据库结构和不同环境的执行方式见 [10 月 14 日 SQL 说明](../updatesql/sql.md);本次问题修复没有额外字段变更;2026-10-14 测试库已按旧版增量路径完成结构与字典更新,其他环境仍需按 SQL 说明执行。

+ 58 - 0
docs/map-api-usage.md

@@ -0,0 +1,58 @@
+# 地图功能使用现状
+
+面向后端维护者的地图能力清单。2026-09-29 起,**百度、Mapbox 的外部接口已标记停用**:代码保留、行为未变,仅加注解与本文记录,禁止新增调用。
+
+## 外部地图服务状态
+
+| 服务 | 能力 | 封装位置 | 状态 |
+|---|---|---|---|
+| Google | Directions 路线 | `GetArea.getluxian` → `/utils/getluxianjvli` | 在用(key 硬编码,切换计划中作废) |
+| Google | Nearby/Geocoding 逆地理 | `GetArea.getgoogle` / `getgoogleadd` → `/utils/getLocationAddr`(type=1)、`/utils/getAddr` | 在用(key 硬编码) |
+| Google | Routes API v2 | `GoogleRoutesDistanceProvider`(闪送报价距离) | 在用(key 走字典 `sys_googlemap_key`;**可经距离来源开关切自部署,见下节**) |
+| Mapbox | Directions cycling | `GetArea.getmpluxian` → `/utils/getluxianjvliMp` | **已停用**(@Deprecated,端点保留) |
+| Mapbox | Directions driving 耗时 | `GetArea.getMotorbikeDuration` | **已停用**(见下方存量调用点) |
+| 百度 | 逆地理 reverse_geocoding | `GetArea.getarearess` → `/utils/getLocationAddr`(type=0) | **已停用**(分支保留兜底) |
+| 百度 | 坐标转换 geoconv | `GetArea.setlatlng` → `/utils/setLocat` | **已停用**(端点保留) |
+
+## 不依赖外部地图的能力(纯数学 / 纯 DB)
+
+| 能力 | 位置 | 实现 |
+|---|---|---|
+| 外卖运费估算 | `/utils/getyunfei` | `GetJvli.getDistance` Haversine 直线 + `pos_freight` 跳表 |
+| 打车计价 | `TaxiPricesController /ganyunfei` | 直线距离 + `taxi_prices` 表 |
+| 两点距离 | `UserOrderController /getDistance`、`GetArea.getDistance` | Haversine 纯数学 |
+| GCJ02→WGS84 | `/utils/GCJ2TOWGS` | 纯数学转换 |
+| 地址簿最近地址 | `/system/address/getzuijinaddress` | DB 排序 |
+| 骑手派单 / 附近订单 | `PosOrderMapper.getqisjvli` / `getstate` | MySQL `st_distance` DB 半径 |
+| 闪送报价兜底 | `FlashDeliveryRouteService` | Google 路线失败时直线距离 + `distanceSource=STRAIGHT_LINE` 落库 |
+
+## 距离来源开关(032 US1,2026-09-29 实现)
+
+字典 `sys_map_distance_source`(平台后台 系统管理-字典管理):
+
+| 值 | 行为 |
+|---|---|
+| `GOOGLE`(默认) | 闪送路线距离走 Google Routes(现状) |
+| `SELF_HOSTED` | 闪送路线距离走自部署地图(Foodie_HCM_Map `GET /route/multi`) |
+
+仅在启用的字典项中,**恰好一条** `is_default=Y` 时才采用该项的值;没有默认项、多个默认项或默认值未知时均回退 `GOOGLE`。安装 SQL 在 `updatesql/sql.md`,会创建 `GOOGLE`(默认)与 `SELF_HOSTED` 两项。切换时在后台把旧项改为 `N`、目标项改为 `Y`;后台保存字典数据会更新缓存,手工执行 SQL 后则需在后台刷新字典缓存或重启服务。新增一行选项或只改排序不会切换。回滚同理,无需发版。
+
+实现:`DistanceSourceRouteProvider`(@Primary 委托)+ `SelfHostedRouteDistanceProvider`(单次请求地图 `GET /route/multi?coords=`,多站点按用户顺序途经、整线道路距离;`map.self-hosted.base-url` 配置地址,默认 `http://127.0.0.1:8080`)。失败语义与 Google 路径一致——路线失败/超时/吸附失败直线兜底并落库 `STRAIGHT_LINE`;**坐标越界(OUTSIDE_REGION)不降级**,报「地址超出可服务范围」。
+
+地图侧配套(2026-09-29,Foodie_HCM_Map 仓库):`routing.py` 泛化 `calculate_route_multi`(N 站点单次 OSRM 请求、逐站吸附校验)+ 新端点 `/route/multi`(`coords=lat,lng;...`,2~30 站),`/route` 两点入口保持不变。多站点错误中,越界或吸附失败可定位时返回从 1 开始的 `point_index`;OSRM `NoSegment` 映射为 `SNAP_TOO_FAR`,整体不可达 `NoRoute` 为 `NO_ROUTE` 且不伪造站点序号。032 US4 的地图侧扩展已落地,foodie 不再需要逐段累加。部署时若走地图 Nginx 的 8080 入口,`map.self-hosted.base-url` 应包含 `/api`;直接访问地图 API 服务则按实际入口配置。
+
+边界:开关目前**只管闪送路线距离**;`/getluxianjvli`、逆地理等代理接口仍在 Google(属 032 US3,需 App 配合)。
+
+## 存量调用点警示
+
+
+- `PosOrderController.setYjsdTime`(state=4 订单的预计送达时间,调用方为改单状态取餐分支与测试端点 `/testYjsdTime`)**已停用**(2026-09-29 标记):方法不再使用,保留调用不影响状态流转;其依赖的 `getMotorbikeDuration`(Mapbox driving ×1.5)因此实际无在用调用。切自部署路线源(032)时可一并清理。
+- `/utils/getLocationAddr` 的 `type=0` 百度分支保留兜底;App 端应使用 `type=1` 谷歌分支。
+- `GetJvli.getOptimalRoute`(Google Routes v2)全库无调用方,属死代码,不在本次标注范围。
+- `GetArea`/`GetJvli` 硬编码的 Google key 与 Mapbox token 已随 git 历史泄漏,切换自部署后应全部作废。
+
+## 关联
+
+- 台湾 OSM 需求差距与后续事项(2026-10-10,先记录、暂不实施):[osm-requirements-assessment.md](../specs/032-self-hosted-map-integration/osm-requirements-assessment.md)。测试服务器已支持独立机车路网,但 foodie 自部署适配尚未传车型;现有 car-only 假设不代表该测试服务现状。
+- 切换规格:`specs/032-self-hosted-map-integration/spec.md`(自部署 Foodie_HCM_Map:PostGIS 搜索 + OSRM 算路 + OpenFreeMap 底图)。2026-10-12 四项机车模型评估已验证,见 [model-evaluation.md](../specs/032-self-hosted-map-integration/model-evaluation.md);plan/tasks 仅覆盖评估,后续生产改造尚未开始。
+- 自部署地图仓库:`D:\QtwCode\foodie\Foodie_HCM_Map`

+ 34 - 0
docs/multi-store-test-hold.md

@@ -0,0 +1,34 @@
+# 多店结算未完成测试暂存记录(并行会话协调)
+
+> 记录时间:2026-10-14
+> 状态:暂存中;待 036 多店结算会话补齐 DTO 后按文末命令还原
+> 背景:同一工作区有两个并行会话——闪送多点取送(specs/024)与多店结算(specs/036)。本记录由 024 会话写入,供协调使用。
+
+## 事项
+
+036 会话的未完成测试 `ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java`(未跟踪文件)引用了尚未创建的 `com.ruoyi.app.order.dto.MultiStoreBatchCreateRequest`、`MultiStoreQuoteRequest`、`MultiStoreQuoteView` 等 DTO,导致 `ruoyi-admin` 模块 `test-compile` 整体失败,阻塞其他会话的定向测试与全量回归。
+
+按用户 2026-10-14 指示:先排除出编译范围,不修改其代码。
+
+## 处理
+
+- 文件已移动到仓库根目录 `.test-hold/MultiStoreCheckoutBatchServiceTest.java.hold`,内容字节未改(5057 bytes 原样),未进入 git 历史。
+- `.test-hold/README.md` 附还原说明;该目录保持未跟踪。
+- 移出后 `mvn -pl ruoyi-admin -am test-compile` 全绿;当日闪送 v2 后端全量回归 1024/1024 通过即在此条件下运行。
+
+## 还原方法
+
+036 会话补齐上述 DTO 后执行(Git Bash):
+
+```bash
+mv .test-hold/MultiStoreCheckoutBatchServiceTest.java.hold \
+   ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java
+```
+
+或 Windows CMD:
+
+```bat
+move .test-hold\MultiStoreCheckoutBatchServiceTest.java.hold ruoyi-admin\src\test\java\com\ruoyi\app\order\MultiStoreCheckoutBatchServiceTest.java
+```
+
+还原后删除 `.test-hold/` 目录与本记录中已完成的部分即可。

+ 159 - 0
docs/oauth-business-login-app-api.md

@@ -0,0 +1,159 @@
+# 商家 / 骑手 App 三方登录接入文档(Apple / Google / LINE)
+
+> 依据 `specs/017-oauth-login`(含 2026-10-08 业务端账号密码绑定增量)整理。
+> 适用端:骑手 App、商家 App(含商家子账号)。用户端 App 的手机号+短信流程不在本文范围。
+
+## 1. 渠道与 provider
+
+| 端 | Apple | Google | LINE |
+|---|---|---|---|
+| 骑手 | `apple_rider` | `google_rider` | `line_rider` |
+| 商家(含子账号) | `apple_merchant` | `google_merchant` | `line_merchant` |
+
+- 三方账号只做**已有业务账号的快捷登录入口**:骑手渠道只能绑已有有效骑手账号,商家渠道只能绑已有有效商家/子账号,**不会自动创建业务账号**。
+- 一个三方账号在同一 provider 下只能绑定一个业务账号;换绑需联系平台处理。
+
+## 2. 总体流程
+
+```
+① App 取三方凭证
+   Apple  :Sign in with Apple SDK → identityToken
+   Google :Google Sign-In SDK → idToken
+   LINE   :先调 /auth/line/authorize 拿授权地址和 state,打开授权页
+② POST /infouser/user/oauthLogin 提交 provider + credential
+   ├─ 已绑定 → 直接返回 { data: 用户信息, token },登录完成
+   └─ 未绑定 → 返回 { data: { status:"needPhone", tempKey } }
+③ 首次绑定:App 弹【账号 + 密码】表单
+   POST /infouser/user/oauthBindPhone  提交 tempKey + userName + password(RSA密文)
+   → 成功返回 { data: 用户信息, token },绑定并登录完成
+```
+
+- `token` 与现有账号密码登录(`syslodeing` / `shanglodeing`)签发的是同类会话 token(骑手端 / 商家端各自独立),后续请求照旧放 `token` 请求头即可,无需其它接入。
+- LINE 走服务端回调时第②步由后端回调完成,App 从回跳 scheme 参数里直接拿到 token 或 tempKey,见第 6 节。
+
+## 3. 接口:签发 LINE 授权地址 `POST /auth/line/authorize`(免登录)
+
+业务端 LINE 登录**必须**先从服务端拿授权地址(内含服务端签发的 state,5 分钟有效、单次消费),不要自行拼接 LINE 授权 URL。
+
+**请求体:**
+
+```json
+{ "provider": "line_rider" }   // 商家端传 line_merchant
+```
+
+**响应:**
+
+```json
+{
+  "code": 200,
+  "data": {
+    "authorizationUrl": "https://access.line.me/oauth2/v2.1/authorize?...",
+    "state": "32位hex"
+  }
+}
+```
+
+App 打开 `authorizationUrl`(系统浏览器或 WebView),并**保存 state** 用于回跳比对。
+
+## 4. 接口:三方登录第一步 `POST /infouser/user/oauthLogin`(免登录)
+
+**请求体:**
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `provider` | 是 | 见第 1 节 |
+| `credential` | 是 | Apple=identityToken;Google=idToken;LINE=授权 code |
+| `state` | LINE 直连提交 code 时必填 | `/auth/line/authorize` 返回的 state;Apple/Google 不传 |
+| `cid` `cidType` `deviceToken` `voIPToken` | 否 | 推送字段,与现有登录一致 |
+| `fullName` | 否 | 仅 Apple:首次授权时用户共享的姓名,从 ASAuthorization 凭证取;后续登录不再传 |
+
+**响应 A(已绑定,直接登录):**
+
+```json
+{ "code": 200, "msg": "...", "data": { ...用户信息... }, "token": "xxx" }
+```
+
+**响应 B(未绑定,首次):**
+
+```json
+{ "code": 200, "msg": "...", "data": { "status": "needPhone", "tempKey": "xxx" } }
+```
+
+- `tempKey` **5 分钟有效、一次性**:过期或任何一步失败,都要重新从 `oauthLogin` 开始,不能重试旧 tempKey。
+- 注意:字段名 `needPhone` 是历史契约(用户端为手机号+短信),**业务端拿到该状态后展示的是「账号 + 密码」表单**,不走短信。
+- 响应 `data` 不含三方渠道字段(骑手返回账号实体、商家返回 `MerchantLoginView`,均只有账号资料);登录渠道 App 自己发起时已知,需要展示时按第 7 节查询。
+
+**失败:** `code != 200`(凭证无效/过期、provider 不支持、账号已停用、state 无效等),直接提示 `msg`。
+
+## 5. 接口:首次绑定 `POST /infouser/user/oauthBindPhone`(免登录)
+
+业务端绑定 = 校验业务账号密码后写入三方绑定,成功即登录。
+
+**请求体:**
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `tempKey` | 是 | `oauthLogin` 返回 |
+| `userName` | 是 | 业务账号登录名(与 `syslodeing` / `shanglodeing` 登录用的账号一致) |
+| `password` | 是 | **RSA 公钥加密后的密码密文**,加密方式与现有账号密码登录完全一致(同一公钥、同一格式,直接复用现有加密函数) |
+| `cid` `cidType` `deviceToken` `voIPToken` | 否 | 推送字段 |
+
+业务端**不传** `phone` / `code`(那是用户端 App 的短信绑定参数)。
+
+**响应(成功):**
+
+```json
+{ "code": 200, "msg": "...", "data": { ...用户信息... }, "token": "xxx" }
+```
+
+绑定成功后即完成登录,无需再调登录接口;该三方账号下次登录走 `oauthLogin` 直接拿 token。
+
+**失败(不写绑定、不签 token):**
+
+| 场景 | 处理建议 |
+|---|---|
+| tempKey 缺失/过期 | 重新发起 `oauthLogin` |
+| 缺 userName / password | 表单校验拦截 |
+| 账号不存在 | 提示 `msg`(账号名或角色不符、已删除均按不存在处理) |
+| 密码错误 | 提示 `msg`,可重输 |
+| 账号停用 / 子账号被禁用或归属失效 | 提示 `msg`,联系平台 |
+
+## 6. LINE 服务端回调 `GET /auth/line/callback`(LINE 服务器调用,App 不直接请求)
+
+LINE 授权完成后,后端处理回调并 **302 跳回对应 App 的 scheme**,App 按查询参数分流:
+
+| 回跳参数 | 含义 | App 动作 |
+|---|---|---|
+| `?token=...&state=...` | 已绑定,登录成功 | 比对 state 后保存 token,进入首页 |
+| `?needPhone=1&tempKey=...&state=...` | 未绑定,首次 | 比对 state 后弹**账号+密码**表单 → 调 `oauthBindPhone` |
+| `?error=no_code\|user_stopped\|fail&state=...` | 失败 | 提示失败,可重试 |
+
+- App 必须比对回跳 `state` 与第 3 节保存的发起值,不一致视为异常放弃本次登录。
+- 若业务端在 H5/webview 内能自己拿到授权 code,也可不走回调,改为直接调 `oauthLogin` 提交 `provider + credential(code) + state`。
+
+## 7. 登录渠道与第三方名称展示(与用户端同一契约)
+
+与用户端 App 一致:登录接口响应不带渠道字段,需要展示「本次登录渠道 + 第三方名称」时调用:
+
+### `GET /infouser/user/getuserinfo`(`token` 请求头;骑手/商家 OAuth 登录签发的 token 均可直接调用)
+
+响应 `data` 中与渠道相关的两个字段(017 2026-09-24 增量,三端共用同一契约):
+
+| 字段 | 说明 |
+|---|---|
+| `oauthProvider` | 本次会话登录渠道:`apple_rider` / `google_rider` / `line_rider` / `apple_merchant` / `google_merchant` / `line_merchant`;账号密码登录的会话该字段为空 |
+| `providerDisplayName` | 该渠道的第三方显示名称(LINE 的 displayName / Apple 首次授权共享的 fullName),**可空** |
+
+**展示规则(与用户端一致):**
+
+- 第三方名称与账号昵称 `nickName` 分开显示;`providerDisplayName` 为空时只展示渠道(如「Apple 登录」),不要用昵称冒充第三方名称。
+- LINE 名称由服务端每次成功登录自动刷新,App 无需额外操作。
+- 这两个字段反映的是**本次会话**的登录方式,不是账号维度的「最后一次登录方式」。
+
+## 8. 注意事项汇总
+
+1. Apple / Google:App 用各自 SDK 在本地取凭证后直接调 `oauthLogin`,无需服务端授权地址。
+2. LINE:必须走 `/auth/line/authorize` 拿授权地址与 state(服务端校验单次消费),state 过期需重新获取。
+3. `tempKey` 一次性、5 分钟 TTL;绑定任何失败都从 `oauthLogin` 重来。
+4. 密码只接受 RSA 密文,与现有登录接口同公钥同格式。
+5. 各端 audience/clientId 独立:Apple/Google 凭证必须用对应业务端 App 的 bundleId/clientId 获取,用户端凭证在业务端会被拒绝。

+ 44 - 0
docs/order-api-usage.md

@@ -0,0 +1,44 @@
+# 外卖订单操作入口清单
+
+面向后端维护者的订单接口职责边界。2026-09-29 起明确:**用户、骑手、商家的订单状态流转操作一律走三个专用操作控制器,不使用 `PosOrderController` 的方法**;新增状态流转类功能不得加到 `PosOrderController`。已在四个控制器类注释与 `add`/`edit` 的 `@Deprecated` 标注同步记录。
+
+## 操作入口(在用)
+
+| 角色 | 控制器 | 路由前缀 | 典型操作 |
+|---|---|---|---|
+| 用户 | `UserOrderController` | `/system/userOrder` | 下单(createOrder,含支付方式闸门)、用户取消 |
+| 骑手 | `PosOrderQsOprateController` | `/system/orderQsOprate` | 接单 / 取餐 / 送达(按 `deliveryStatus` 1/2/3) |
+| 商家 | `PosOrderShOprateController` | `/system/orderShOprate` | **商家端下单(/createOrder)**、出餐(chuCan)、商家取消 |
+
+闪送订单不在本清单内,走 `flashdelivery` 包的独立控制器体系。
+
+## PosOrderController(/system/order)职责边界
+
+仍在用:
+
+| 类别 | 接口 |
+|---|---|
+| 查询 | `/getorderlist`、`/getstoreorderlist`、`/getqsorderlist`、`/getqishouorderlist`、`/getorderxq`、`/getorderhout`、`/getuserfootlist`、`/getPsOrdersPosition`、`/list`、`/shlist1`~`/shlist3`、`/export`、`/{id}`、`/{id}/status-context` |
+| 平台改状态 | `/setorderuzt` |
+| 平台管理操作 | `/{id}/status`、`/{id}/line-payment/reconcile`、`/{id}/line-refund`、`/{id}/offline-payment/confirm`、`/{id}/offline-refund/confirm`(Spring Security `@PreAuthorize`,操作留痕 `@Log`) |
+
+已废弃(`@Deprecated` 已标注,代码保留备查,勿增量修改):
+
+| 接口 | 说明 |
+|---|---|
+| `/addorder` | 旧代客外送下单流程(货到付款 + sendHdfkMessage 推送);2026-09-29 确认废弃,商家端下单走 `PosOrderShOprateController /createOrder`。内含 031 支付闸门 `assertUsable` 调用点,随入口一并废弃,勿再以此为基准增量开发 |
+| `POST /system/order`(`add`) | RuoYi CRUD 新增订单;用户下单走 `UserOrderController.createOrder` |
+| `PUT /system/order`(`edit`) | 修改订单信息入口不再使用;状态流转已迁出 |
+| `/testYjsdTime` | 随 `setYjsdTime` 停用(依赖已停用的 Mapbox 接口,见 map-api-usage.md) |
+
+## 新改动规则
+
+1. 状态流转类新功能(改 state / deliveryStatus / payStatus / afterSaleStatus 的操作)一律实现到对应角色控制器,不进 `PosOrderController`。
+2. `PosOrderController` 仅维护查询接口、平台管理接口与既有的 `/addorder`、`/setorderuzt`。
+3. 废弃方法不做增量修改;排查问题时也不得以其为基准(同 CLAUDE.md 已废弃代码清单约定)。
+
+## 关联
+
+- `CLAUDE.md` 已废弃代码清单(订单/支付)
+- `specs/030-unified-order-list/`(统一订单列表,列表查询口径)
+- `docs/map-api-usage.md`(`/testYjsdTime`、`setYjsdTime` 停用详情)

+ 7 - 5
docs/order-push-overview.md

@@ -1,6 +1,6 @@
 # 訂單推送說明(測試參考)
 
-**最後更新**: 2026-06-25
+**最後更新**: 2026-09-29
 **用途**: 列出訂單流程中所有會觸發推送的場景,供測試驗證。
 
 > 本文推送文案以**繁體中文**展示。實際推送時文案會跟隨接收人的語言設置自動切換(支援 簡中 / 越南語 / 英文 / 繁中),但下方表格統一以繁中舉例。
@@ -9,8 +9,10 @@
 
 ## 一、推送基本說明
 
-- 每條推送會出現在**兩個地方**:① 手機通知欄(僅 iOS);② App 內「消息中心」列表(iOS / Android 都有)。
-- **Android 手機沒有通知欄推送**(暫未接入 Android 推送通道),只能在 App 內消息列表看到。
+- 目前服務端普通業務推送**預設仍走 uniPush**;現有測試環境的 iOS 通知欄與 App「消息中心」按原流程核對。消息中心由原業務路徑寫入,外部推送投遞成功與否不能只憑消息記錄判定。
+- 服務端已有 Android FCM / iOS APNs 原生通道,但**尚未啟用**,也未包含 App 端 token 上報改造;不要把原生通知欄接收當成本文件目前的既有結果。啟用條件與真機驗收見 `specs/033-native-push-routing/quickstart.md`。
+- 外賣與閃送新單對多名騎手的通知:預設 uni 模式維持原本逐人發送;切到原生模式後,Android 騎手按相同語言文案及訂單資料走騎手端 FCM multicast,iOS 騎手仍逐設備走 APNs。每人的消息中心記錄保持獨立。
+- 商家門店訂單通知沿用「已登入且有當前通道可用地址的分管帳號優先;沒有符合條件的分管帳號才由主帳號兜底」的規則,兩類帳號不會同時通知。原生模式用當前 `deviceToken` 判斷,預設 uni 模式用 `cid`。
 - 推送語言**跟隨接收人的語言設置**(簡中 / 越南語 / 英文 / 繁中),同一場景不同語言賬號看到的文案不同。
 - 正文中的 `{訂單號}` 是實際訂單編號,會替換成真實號碼。
 
@@ -56,7 +58,7 @@
 每個場景觸發後,按接收人登錄對應端(用戶 App / 商家端 / 騎手端)檢查:
 
 1. **iOS**:通知欄收到推送 + App 內消息列表有新記錄。
-2. **Android**:App 內消息列表有新記錄(通知欄**不會**有,屬正常)。
+2. **Android(目前預設 uni 模式)**:核對 App 內消息列表;FCM 原生通知欄需要切換原生通道及 App 端配套後另行真機驗證。
 3. **文案**:標題和正文與上表一致;正文末尾的訂單號與實際訂單一致。
 4. **多語言**:把接收賬號切到不同語言,文案應隨之變成對應語言。
 5. **接收人**:確認只有「推送對象」列裡的人收到,其他人不收到。
@@ -67,4 +69,4 @@
 
 - **貨到付款下單(場景 2)的通知不帶訂單號**:商家 / 騎手通知欄只看到「有新訂單」,看不到具體訂單號(點擊進 App 仍可看到訂單詳情)。
 - **商家出餐文案略冗餘**:顯示為 `商家已經出餐,訂單號:,NO:123`(冒號和逗號連在一起),現狀如此。
-- **暫不存在的推送**(本次不在範圍,測試時不用驗證):取消訂單、退款、超時無騎手、抽成返還、自動恢復營業等通知,以及 Android 通知欄推送。
+- **暫不存在的業務通知**(本次不在範圍,測試時不用驗證):取消訂單、退款、超時無騎手、抽成返還、自動恢復營業等通知。Android FCM 服務端能力已實作,但原生模式未啟用、真機推送仍待驗證。

+ 341 - 0
docs/pay-method-config-app-api.md

@@ -0,0 +1,341 @@
+# 支付方式配置 App 接入文档
+
+本文档供用户端 App 和骑手端 App 接入支付方式配置(031)使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。商家 PC 端(foodie-store)与平台后台的支付方式管理页面已另行完成,不在本文档范围。
+
+> 更新时间:2026-09-29。本文覆盖 031 全量 App 侧增量:用户端结算页支付方式查询、现金自取/堂食语义、闪送支付方式与确认收款、骑手端收款方式设置与银行卡管理。
+
+当前契约要点:
+
+- 支付方式可用性由后端「平台开关 × 收款方选择 × 就绪度」三层闸门统一判定。App 一律以查询接口的返回渲染可选支付方式,不得本地写死可用性;下单提交未开放的支付方式会被后端拒绝。
+- 支付方式按「组代码 methodCode」管理,一个组可对应一个或多个 `payType` 数值;App 对用户展示自己的文案,下单时提交 `payType` 数值。
+- 用户端餐饮结算页新增匿名接口 `GET /system/storePayMethods`,替代旧版"按 bankInfo 是否有数据判断线下转账显隐"的做法;`bankInfo` 接口继续负责线下转账的收款信息内容,路径与返回结构不变。
+- 现金(payType=4)仅自取(type=1)和堂食(type=2)可用;现金订单创建后直接进入"进行中"(payStatus=1),不经"待付款"。
+- 闪送首页与报价响应新增 `payMethods` 列表;下单 `payType` 缺省为现金 4,线下转账为 6;订单视图新增 `paymentStatus`,线下转账单送达后向寄件人展示骑手收款卡。
+- 骑手端新增收款方式设置、本人银行卡管理和送达后确认收款三个能力;骑手待抢列表按骑手接受的支付方式过滤订单。
+
+## 1. 功能范围
+
+当前支持:
+
+- 平台按"商家餐饮"和"骑手闪送"两个维度独立开关支付方式;开关关闭的方式对所有相关方不可见、不可用。
+- 商家在商家 PC 端勾选自己接受的支付方式(未设置视为全部接受);本文档的查询接口已折算该选择。
+- 线下转账的就绪度按收款方是否有启用银行卡判定;商家与骑手各自维护本人(商家为主账号)银行卡。
+- 用户餐饮结算页按门店查询可用支付方式,含就绪度标记;现金仅出现在自取/堂食场景。
+- 餐饮现金自取/堂食下单:商家接受现金时可用,订单创建后 payStatus=1,直接进入进行中。
+- 闪送下单选择现金(默认)或线下转账;骑手待抢列表按骑手接受的支付方式过滤;送达后骑手确认收款,寄件人在送达后看到骑手收款卡用于线下转账结清。
+
+当前不包含:在线支付新渠道接入、退款、找零、开票;这些仍走各自既有流程。
+
+## 2. 通用约定
+
+### 2.1 基础地址和请求头
+
+接口路径均为相对路径,实际请求地址为 `{baseUrl}{接口路径}`。
+
+| 请求头 | 必填 | 说明 |
+|---|---:|---|
+| `token` | 标注"登录"的接口必填 | App 登录后取得的 JWT。不要放在 `Authorization` 中 |
+| `Content-Type` | POST/PUT JSON 接口必填 | `application/json` |
+
+用户身份、骑手身份、商家身份均由 `token` 解析,请求体中不提交 `userId`。标注"匿名"的接口不需要 `token`,携带了也会被忽略。
+
+### 2.2 统一响应
+
+与闪送接口一致:App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 状态码。`msg` 已由后端国际化,可直接用于错误提示。
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {}
+}
+```
+
+业务失败时 `code=500`,`msg` 为国际化文案;登录失效时 `code=401`。
+
+## 3. 支付方式枚举
+
+### 3.1 组代码与 payType 对照
+
+| 组代码 `methodCode` | `payType` | 含义 | 说明 |
+|---|---|---|---|
+| `COD` | `1` | 货到付款 | 默认开放,无额外就绪度要求 |
+| `CARD_OMG` | `2` / `5` | 信用卡 / Apple Pay | 同组同开同关,就绪度一致;接口按 payType 展开为两项 |
+| `LINE_PAY` | `3` | LINE Pay | — |
+| `CASH` | `4` | 现金 | 仅商家餐饮维度;且仅自取/堂食场景返回与可用 |
+| `OFFLINE_TRANSFER` | `6` | 线下转账 | 就绪度取决于收款方启用银行卡 |
+
+下单接口提交的是 `payType` 数值字符串;查询接口返回的是 `methodCode` + `payType` 成对出现,App 按 `payType` 回传。
+
+### 3.2 各端可用集合
+
+| 维度 | 可出现的组 | 说明 |
+|---|---|---|
+| 商家餐饮(用户结算页) | `COD`、`CARD_OMG`、`LINE_PAY`、`OFFLINE_TRANSFER`、`CASH` | `CASH` 仅当查询参数 `type` 为 1 或 2 且商家接受时出现 |
+| 骑手闪送(首页/报价、骑手设置) | `COD`、`LINE_PAY`、`OFFLINE_TRANSFER` | 闪送款项直达骑手,无线上卡支付组;现金为缺省支付,不在列表中 |
+
+最终可用 = 平台开关(关闭的组不返回)∩ 收款方勾选(未设置视为全部)∩ 就绪度。App 只按接口返回渲染。
+
+### 3.3 闪送订单 `paymentStatus`
+
+| 值 | 含义 |
+|---|---|
+| `0` | 骑手尚未确认收到款项(默认) |
+| `1` | 骑手已确认收到款项 |
+
+`paymentStatus` 出现在闪送用户订单列表/详情和骑手订单列表/详情中。它只表示骑手侧的线下收款确认,不发起任何在线支付或扣款。
+
+## 4. 用户端接口
+
+### 4.1 结算页可用支付方式
+
+```http
+GET /system/storePayMethods?storeId={storeId}&type={type}
+```
+
+匿名接口,无需 `token`。App 进入餐饮结算页时调用,按返回渲染支付方式列表。
+
+查询参数:
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `storeId` | 是 | 门店 ID;门店不存在时返回空数组 |
+| `type` | 否 | 订单类型:`1` 自取、`2` 堂食、`0` 外送 |
+
+响应 `data` 为数组,每项:
+
+```json
+[
+  { "methodCode": "COD", "payType": "1", "ready": true },
+  { "methodCode": "CARD_OMG", "payType": "2", "ready": true },
+  { "methodCode": "CARD_OMG", "payType": "5", "ready": true },
+  { "methodCode": "OFFLINE_TRANSFER", "payType": "6", "ready": false }
+]
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `methodCode` | string | 组代码,取值见 3.1 |
+| `payType` | string | 下单时提交的支付方式数值 |
+| `ready` | boolean | 就绪度;`false` 的项 App 不得渲染(如商家未启用银行卡的线下转账) |
+
+规则:
+
+- `type=1` 或 `2` 且商家接受现金(勾选含 `CASH` 或未设置)时,返回中追加 `{ "methodCode": "CASH", "payType": "4", "ready": true }`。
+- `type=0`(外送)或不传 `type` 时不返回现金项。老版本 App 不传 `type`,行为与旧接口完全一致。
+- 用户切换自取/堂食/外送后必须重新调用本接口刷新列表。
+- 线下转账 `ready=false` 时不渲染;`ready=true` 时渲染选项,但收款账户信息仍从 4.3 的 `bankInfo` 获取。
+
+### 4.2 餐饮下单提交支付方式
+
+下单仍走既有 `UserOrderController.createOrder`,支付方式字段的增量语义:
+
+- 提交的 `payType` 必须是 4.1 返回过的值(或老版本既有的到付/卡/LINE Pay 值);提交后端判定不可用的方式会返回 `code=500`、`msg=该支付方式当前不可用,请更换支付方式`,订单不会创建。
+- 现金(`payType=4`)仅当订单 `type=1` 自取或 `2` 堂食且商家接受时可用;外送订单直传 `4` 一律被拒绝。
+- 现金订单创建成功后 `payStatus=1`(同到付的线下收款语义),订单直接出现在"进行中",不会出现在"待付款"页签。
+- 现金订单的取消、售后行为与到付订单一致。
+
+### 4.3 线下转账收款信息
+
+```http
+GET /chanting/store/bankInfo?id={storeId}
+```
+
+匿名接口,路径、参数与返回结构保持不变。用户选择线下转账后,用 `data` 中的 `bankAccountName`、`bankName`、`bankAccountNo` 三字段展示收款信息;商家无启用银行卡或线下转账未开放时 `data=null`,App 隐藏收款信息区。
+
+本接口只管"转账信息内容",选项显隐一律以 4.1 的 `ready` 为准,不要再按 `bankInfo` 是否有数据决定支付方式选项的显隐。
+
+### 4.4 闪送支付方式(既有接口增量)
+
+闪送首页 `GET /system/flashDelivery/home` 与报价 `POST /system/flashDelivery/quote` 的响应新增 `payMethods` 字段:
+
+```json
+{
+  "payMethods": [
+    { "methodCode": "OFFLINE_TRANSFER", "payType": "6", "ready": true }
+  ]
+}
+```
+
+- 列表是现金之外的可选支付方式;现金(`payType=4`)为缺省值,恒可用,不在列表中。
+- `ready=false` 的项不渲染(如当前没有启用银行卡的骑手群体时,平台仍开放线下转账,但就绪度需按返回处理;用户下单时以列表返回为准)。
+- App 按 `payMethods` 渲染选项,不要本地写死只有"现金/线下转账"两项;未来平台开放其他闪送支付方式时无需改版。
+
+创建闪送订单 `POST /system/flashDelivery/orders` 的请求体新增可选字段 `payType`:
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `payType` | string | 否 | 支付方式:缺省 `4` 现金;`6` 线下转账。闪送款项直达骑手,下单后锁定,不可修改 |
+
+提交列表之外的值会被拒绝:`msg=该支付方式当前不可用,请更换支付方式`。
+
+闪送订单详情与列表新增 `paymentStatus`(见 3.3)。寄件人视角的订单详情,在线下转账单状态到达 `DELIVERED` 或 `COMPLETED` 后,额外返回 `riderBankCard`:
+
+```json
+{
+  "riderBankCard": {
+    "bankName": "台湾银行",
+    "accountNo": "123456789012",
+    "accountName": "陈骑手"
+  }
+}
+```
+
+- 仅寄件人视角、且订单 `payType=6`、状态为已送达或已完成时返回;其他情况该字段为 `null`,App 不展示。
+- 该卡片是中单骑手的启用收款卡,用于寄件人线下转账结清;转账完成由骑手确认(见 5.5),确认后 `paymentStatus=1`。
+
+## 5. 骑手端接口
+
+所有接口均要求骑手登录 `token`,且账号 `userType` 必须为 `2`;非骑手调用按无权限处理(`msg=用户已停用`)。
+
+### 5.1 查询收款方式设置
+
+```http
+GET /system/riderFlashPayMethods
+```
+
+响应 `data`:
+
+```json
+{
+  "available": [
+    { "methodCode": "COD", "payType": "1", "ready": true },
+    { "methodCode": "LINE_PAY", "payType": "3", "ready": true },
+    { "methodCode": "OFFLINE_TRANSFER", "payType": "6", "ready": false }
+  ],
+  "selected": ["OFFLINE_TRANSFER"]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `available` | 平台闪送维度开放的支付方式全集,附就绪度;线下转账的 `ready` 表示骑手本人当前是否有启用银行卡 |
+| `selected` | 骑手当前勾选;空数组表示未设置,语义为"接受全部可用方式" |
+
+骑手设置页按 `available` 渲染勾选列表,`ready=false` 的项可展示但应标注未就绪原因(如"请先添加银行卡")。
+
+### 5.2 保存收款方式
+
+```http
+PUT /system/riderFlashPayMethods
+```
+
+请求体:
+
+```json
+{
+  "methodCodes": ["OFFLINE_TRANSFER"]
+}
+```
+
+- `methodCodes` 内的每个值必须是 5.1 `available` 中出现过的 `methodCode`,否则返回 `msg=支付方式设置参数不合法`。
+- 空数组或传 `[]` 表示清空选择,回落"接受平台开放的全部方式"。
+- 保存成功无 `data`。保存后立即影响本人待抢列表的订单过滤。
+
+### 5.3 银行卡管理
+
+骑手本人收款卡的新增、维护。线下转账就绪度取决于是否存在 `isActive=1` 的卡。
+
+```http
+GET    /system/bankCard
+POST   /system/bankCard
+PUT    /system/bankCard
+DELETE /system/bankCard/{id}
+PUT    /system/bankCard/activate/{id}
+```
+
+查询列表响应 `data` 为数组,启用卡排在前:
+
+```json
+[
+  {
+    "id": 3,
+    "userId": 20001,
+    "bankName": "台湾银行",
+    "accountNo": "123456789012",
+    "accountName": "陈骑手",
+    "isActive": 1,
+    "createTime": "2026-09-20 10:00:00",
+    "updateTime": "2026-09-20 10:00:00"
+  }
+]
+```
+
+新增/修改请求体(新增不传 `id`,修改必传):
+
+```json
+{
+  "id": null,
+  "bankName": "台湾银行",
+  "accountNo": "123456789012",
+  "accountName": "陈骑手"
+}
+```
+
+| 字段 | 必填 | 规则 |
+|---|---:|---|
+| `bankName` | 是 | 最长 64 字符;必须与平台字典 `taiwan_bank_list` 的银行名称完全一致,否则 `msg=银行名称不合法` |
+| `accountNo` | 是 | 最长 64 字符 |
+| `accountName` | 是 | 最长 64 字符 |
+
+规则:
+
+- 同一骑手最多 10 张卡,超出返回 `msg=最多只能保存10张银行卡`。
+- 第一张卡新增后自动启用;之后新增的卡默认停用,需调用启用接口。
+- 启用接口 `PUT /system/bankCard/activate/{id}` 会自动停用原启用卡,保证同一骑手最多一张启用卡;启用后付款方下次查询即看到新卡。
+- 删除启用卡后本人回到"无启用卡"状态,线下转账不再就绪。
+- 只能操作本人卡;操作他人卡 ID 按无权限处理。
+- 骑手订单列表按支付方式过滤后,未启用银行卡且未勾选线下转账的骑手看不到线下转账订单;启用卡并勾选后新订单立即可见。
+
+### 5.4 抢单列表的支付方式过滤(行为说明)
+
+`GET /system/flashDelivery/rider/orders` 的 `newTask` 列表按当前骑手接受的支付方式过滤:
+
+- 现金(`payType=4`)订单对所有人恒可见,不受勾选影响。
+- 线下转账订单只对"平台开放 ∩ 本人勾选(或未设置)∩ 本人存在启用银行卡"的骑手可见。
+- 骑手列表摘要的 `payType` 字段照常返回;App 可据此在订单卡片上展示"线下转账"标识,提示送达后需向寄件人收款并确认。
+
+### 5.5 确认收款
+
+```http
+POST /system/flashDelivery/rider/orders/{id}/confirmPayment
+```
+
+无请求体。`{id}` 为订单 ID(路径参数)。
+
+条件与效果:
+
+- 仅本人中单的订单;非本人订单返回 `msg=快送订单不存在或无权访问`。
+- 订单状态必须已达 `DELIVERED` 或 `COMPLETED`;未送达时返回 `msg=订单尚未送达,不能确认收款`。
+- 成功后订单 `paymentStatus` 0→1,并写入操作日志;状态推进本身不受影响。
+- 幂等:`paymentStatus` 已为 1 时重复调用仍返回成功。
+
+App 流程:线下转账单送达后,在订单详情向骑手展示"确认收款"入口(按 `paymentStatus=0` 且状态已送达/已完成显示);点击确认成功后刷新本地 `paymentStatus`,入口变为已确认状态。现金单同样可以确认,但 App 可按 `payType` 决定是否强调该入口。
+
+## 6. 常见业务错误与处理建议
+
+| `msg` 含义 | 常见原因 | App 处理建议 |
+|---|---|---|
+| 该支付方式当前不可用,请更换支付方式 | 平台关闭、收款方未勾选、就绪度不满足,或现金用于外送订单 | 重新拉取可用支付方式列表,让用户换选 |
+| 支付方式设置参数不合法 | 骑手保存了列表之外的 `methodCode` | 重新拉取 5.1 数据后按返回值提交 |
+| 银行卡信息不合法 | 必填缺失、超长或修改时缺 `id` | 返回表单修正 |
+| 银行名称不合法 | `bankName` 不在 `taiwan_bank_list` 字典 | 银行名称必须从字典/下拉选择,不允许自由输入 |
+| 最多只能保存10张银行卡 | 卡数达上限 | 提示先删除不用的卡 |
+| 订单尚未送达,不能确认收款 | 确认收款时订单未到 `DELIVERED` | 隐藏入口,送达后再展示 |
+| 快送订单不存在或无权访问 | 确认收款时订单非本人中单或已不存在 | 返回列表并刷新 |
+| 用户已停用 | 非骑手调用骑手端接口,或账号状态异常 | 引导重新登录或联系平台 |
+| token 已过期 | 登录态失效 | 清理登录态并重新登录 |
+
+## 7. 接入验收清单
+
+- 结算页支付方式列表只按 `GET /system/storePayMethods` 的返回渲染,不本地写死;`ready=false` 的项不渲染。
+- 切换自取/堂食/外送时重新查询支付方式列表;现金项仅在自取/堂食出现。
+- 线下转账的选项显隐以 `ready` 为准;收款信息仍取自 `bankInfo`,两个接口各司其职。
+- 现金订单创建成功后直接按"进行中"展示,不出现"待付款"状态。
+- 闪送首页/报价按 `payMethods` 渲染可选支付方式,现金为缺省项;创建订单原样回传所选 `payType`,缺省不传即现金。
+- 寄件人仅在线下转账单送达/完成后展示 `riderBankCard` 三要素,其余状态不展示。
+- 骑手设置页按 `available` 渲染、按 `selected` 回显;空勾选保存后语义为"接受全部"。
+- 骑手银行卡银行名称从字典下拉选择,不允许自由输入;首张卡自动启用、多卡单启用由 activate 接口保证。
+- 骑手"确认收款"入口仅在 `paymentStatus=0` 且状态为已送达/已完成时展示;确认成功后刷新为已确认,重复点击不报错。
+- 待抢列表对线下转账订单可展示"线下转账"标识,金额文案仍为"订单金额",不使用"收益"。
+- 所有接口以响应体 `code` 判断成功失败;`msg` 直接展示,不做本地拼接。

+ 40 - 0
docs/platform-food-audit-api.md

@@ -0,0 +1,40 @@
+# 平台商品审核接口
+
+日期:2026-10-13。
+
+## 修复原因
+
+平台商品管理的审核开关曾调用 `PUT /chanting/food/changerecommend`。该方法为商家入口,要求商家 `token` 请求头、`@Auth` 和门店归属校验;平台使用 `Authorization: Bearer ...` 登录,因此出现缺少 `token` 的错误。不能把平台令牌伪装为商家令牌,也不能删除商家接口的身份/归属校验。
+
+## 平台专用方法
+
+`PUT /chanting/food/audit`
+
+- 登录与授权:现有平台 Authorization 机制,沿用 `chanting:food:edit` 权限;非匿名接口,不读取商家 token。
+- 请求 DTO 仅包含 `id`(正整数)与 `toExamine`(字符串 `"0"` 或 `"1"`)。`"1"` 为审核通过,`"0"` 为撤销审核;不改变商品本身的 `stackingUp` 上下架字段含义。
+- 服务端只构造 `id/toExamine` 的更新对象,使用现有 UPDATE,不能插入不存在的商品,不处理或删除规格关联,不能借审核改门店、价格、名称、推荐或图片。
+- 参数不合法返回本地化 `food.audit.invalid`;未更新到记录返回原 AjaxResult 失败格式。平台请求失败或取消确认时,审核开关恢复原值。
+- 操作沿用平台更新日志;商家 `/changerecommend` 完全不变。列表、详情和通用商品编辑接口不在本次调整范围内。
+
+```json
+{"id":9137,"toExamine":"1"}
+```
+
+成功响应为现有 AjaxResult `code=200`,业务失败 `code=500`;HTTP 200 本身不代表审核已成功。无数据库字段、结构或菜单权限新增,无需执行 SQL。
+
+## 验收
+
+1. 平台使用具备商品编辑权限的账号,切换审核通过/撤销,应请求 `/chanting/food/audit`,不再报缺少商家 token。
+2. 只有列表权限或未登录时不能执行审核;开关使用原商品编辑权限控制。
+3. 刷新列表核对审核结果;同时确认价格、门店归属、推荐、商品上架字段和规格均未变化。
+4. 非法 ID/状态、不存在商品、取消确认和请求失败不能产生错误成功提示;失败时开关恢复。
+5. 原商家接口仍要求商家登录与归属校验。
+
+自动测试使用模拟数据库访问,不连接实际数据库;部署后仍需验证真实平台登录、角色权限与数据库更新。
+
+## 本次本地验证
+
+- 已先复现原页面调用商家接口以及缺少平台审核方法的失败用例,再验证修复;商品审核新增 6 个 Java 用例和 2 个前端行为用例。
+- JDK 21 商品 Controller 与商家认证定向测试通过;`mvn -pl ruoyi-admin -am test -q` 全回归通过(admin/system 报告合计 1155 项、0 失败、0 错误、0 跳过)。`mvn -pl ruoyi-admin -am -DskipTests package -q` 模块打包通过,打包命令本身不运行测试。
+- 平台全部 Node 行为测试 27/27 通过;`food.js` ESLint 通过;staging 构建成功,保留既有两个 `.vue~` 备份文件和包体积警告,未修改无关文件。
+- 未执行:真实平台登录、无权限账号拦截、实际 MySQL 更新和部署后页面冒烟。上述 MockMvc 使用独立 Controller,权限注解为契约校验,不代表已跑完整 Spring Security/真实令牌集成。

+ 221 - 0
docs/stall-qualification-app-api.md

@@ -0,0 +1,221 @@
+# 摊位资质认证(034):App 端接入说明
+
+**日期**:2026-10-10
+**分支**:`test-202609v2`(后端已实现并过定向测试;测试库 `stall_qualification` 两张表已建)
+**一句话**:摊位(`userType=4` 账号)在 App 里提交认证资料、查看审核进度、驳回后重提;**审核通过前摊位不能营业和接新单**。
+
+---
+
+## 1. 功能与角色
+
+- 认证归属**摊位**(`storeId`),不归属登录账号。一个摊位可以有多个管理账号,共用同一套认证资料;换账号、停用账号都不影响已通过的资质。
+- App 端只做**摊主侧**:填表、传图、存草稿、提交、看进度、看驳回原因、重提。
+- 平台审核(通过/驳回)在平台管理 PC 端操作,App 不涉及。夜市主看进度是 PC 端功能(接口见第 8 节,App 如需也可用)。
+- 所有摊位同一张表单、同一套必填规则,**没有**个人经营/公司经营的分类分支。
+
+## 2. 通用约定
+
+| 项 | 说明 |
+|---|---|
+| 鉴权 | 请求头 `token: <登录令牌>`,账号必须是有效的 `userType=4`(摊主)且已绑定 `isStall=1` 的摊位;否则返回 `merchant.store.access.denied` |
+| 响应包裹 | `{ "code": 200, "msg": "...", "data": ... }`;业务失败 `code=500`,`msg` 为服务端按语言下发的本地化文案(zh/tw/en/vi/th),**App 直接展示 msg 即可,不要自己映射错误文案** |
+| 图片 BASE | 新上传图片是公开路径,直接 `BASE_URL + 路径` 展示,无需登录 |
+
+## 3. 状态模型
+
+`application.status`:
+
+| 值 | 含义 | App 表现 |
+|---|---|---|
+| `0` | 草稿 | 可继续编辑、可提交 |
+| `1` | 待审核 | **只读**,不能改不能重复提交(服务端拒绝 `stall.qualification.pending`) |
+| `2` | 通过 | 当前有效资质;重新提交期间保持有效,新申请通过才切换 |
+| `3` | 驳回 | 展示 `rejectReason`,按新版本重新填写提交 |
+
+`GET /stall/qualification` 里 `currentApplication` 为 `null` 表示从未创建过申请(此时 `version=0`)。
+
+**版本并发**:`view.version` 是乐观锁版本号。每次保存/提交/审核都会 +1。保存请求必须带**当前** `version`;不匹配返回 `stall.qualification.conflict`(提示用户刷新重新加载,**不要自动重试覆盖**——可能是另一个管理账号刚改过)。提交成功后从响应里取新的 `version` 存本地。
+
+## 4. 接口
+
+### 4.1 查询资质视图
+
+```http
+GET /stall/qualification
+token: <摊主登录令牌>
+```
+
+```json
+{
+  "code": 200,
+  "data": {
+    "storeId": 194,
+    "stallName": "阿美蚵仔煎",
+    "stallAddress": "台北市士林夜市 108 摊",
+    "nightMarketId": 12,
+    "version": 3,
+    "currentApplication": { "...": "完整申请对象,见 4.4" },
+    "approvedApplication": { "...": "当前有效版本,可能为 null" },
+    "history": [
+      {
+        "id": 88,
+        "storeId": 194,
+        "applicationVersion": 2,
+        "status": 3,
+        "stallNameSnapshot": "阿美蚵仔煎",
+        "stallAddressSnapshot": "台北市士林夜市 108 摊",
+        "nightMarketIdSnapshot": 12,
+        "submittedAt": "2026-10-10T09:00:00+08:00",
+        "reviewedAt": "2026-10-10T11:00:00+08:00",
+        "rejectReason": "证件照片不清晰"
+      }
+    ]
+  }
+}
+```
+
+- 读操作不会创建草稿;从未提交过时 `version=0`、两个 application 都是 `null`、`history=[]`。
+- `history` 只有摘要(无姓名/证件号/电话/附件),按 `applicationVersion` 倒序。
+- 摊位名称、地址、所属夜市由服务端从 `pos_store` 固化快照,**App 不提供修改入口**。
+
+### 4.2 保存草稿 / 提交审核
+
+```http
+POST /stall/qualification/draft     # 存草稿,允许不完整
+POST /stall/qualification/submit    # 提交审核,四组资料必填
+Content-Type: application/json
+token: <摊主登录令牌>
+```
+
+请求体(两个接口相同):
+
+```json
+{
+  "version": 3,
+  "responsibleName": "王大明",
+  "contactPhone": "0912345678",
+  "identityNumber": "A123456789",
+  "identityImageUrls": ["/profile/upload/2026/10/10/xxx.jpg"],
+  "stallImageUrls": ["/profile/upload/2026/10/10/yyy.png"],
+  "foodRegistrationNumber": "F-200000000-00000",
+  "foodRegistrationImageUrls": ["/profile/upload/2026/10/10/zzz.jpg"]
+}
+```
+
+| 字段 | 必填(提交时) | 限制 |
+|---|---|---|
+| `version` | 是(草稿也要) | 必须等于当前 `view.version`,否则冲突错误 |
+| `responsibleName` | 是 | ≤100 字符 |
+| `contactPhone` | 是 | ≤32 字符;与账号登录手机号独立,可以不同 |
+| `identityNumber` | 是 | ≤64 字符(身份证/护照号) |
+| `identityImageUrls` | 是 | 1~5 张 |
+| `stallImageUrls` | 是 | 1~5 张 |
+| `foodRegistrationNumber` | 是 | ≤64 字符 |
+| `foodRegistrationImageUrls` | 是 | 1~5 张 |
+
+规则:
+
+- 文本两端 trim,trim 后为空视为缺失;超长返回 `stall.qualification.input.invalid`。
+- 草稿也校验已填图片引用的合法性(数量上限、文件存在),只是允许字段不齐。
+- 不接收 `storeId`、状态、审核人等字段——归属由 token 决定。
+- 草稿重复保存是**覆盖同一条草稿**(`applicationVersion` 不变);从已提交/已审核状态再保存(提交后想改、驳回重提)会创建**新版本申请**,旧记录不可变。
+- 响应为保存后的完整 `QualificationView`(同 4.1 结构,`currentApplication` 即刚保存的申请),**从响应更新本地 `version`**。
+
+### 4.3 图片上传(商品同款通用上传)
+
+```http
+POST /utils/Upload
+Content-Type: multipart/form-data
+(字段名 file;接口匿名,可不带 token)
+```
+
+响应:
+
+```json
+{ "code": 200, "msg": "上传成功", "data": "/profile/upload/2026/10/10/20261010xxxx.jpg" }
+```
+
+- `data` 就是可保存进 `xxxImageUrls` 的路径,直接使用。
+- 后端会校验真实图片格式并规范文件名特殊字符,App 无需预处理文件名。
+
+**展示规则(重要)**:
+
+| 引用形态 | 判别 | 展示方式 |
+|---|---|---|
+| 新公共路径 | 以 `/profile/upload/` 开头 | `BASE_URL + 引用`,公开访问,无需登录 |
+| 旧私有引用 | `"<storeId>/<uuid>.jpg|.png"` 形态 | 走 4.4 鉴权接口取 blob 再展示 |
+
+### 4.4 历史申请详情 / 旧私有附件
+
+```http
+GET /stall/qualification/applications/{id}
+token: <摊主登录令牌>
+```
+
+返回完整 `StallQualificationApplication`(含材料、提交/审核时间、驳回原因)。只能看**本摊位**的,看别人的返回 `merchant.store.access.denied`。用于点开 `history` 记录查看当时提交的材料。
+
+```http
+GET /stall/qualification/files/{storeId}/{fileName}
+token: <摊主登录令牌>
+```
+
+旧私有附件读取(`stall-qualification` 目录的历史文件),返回图片二进制(`Cache-Control: no-store`),App 需带 token 请求并转 blob 展示;只能读本人摊位的文件。**新上传不要用 `/stall/qualification/files`(POST)**,那是旧版兼容接口。
+
+## 5. 营业门禁(App 需要处理的联动)
+
+`isStall=1` 且无通过记录时(含从未提交):
+
+| 场景 | 行为 |
+|---|---|
+| 摊主 App 创建订单(代客/堂食/外送) | 服务端拒绝,`msg` = 摊位材料尚未审核通过,暂不能营业或建立新订单 |
+| 用户 App 向该摊位下单 | 同上被拒 |
+| 门店状态 | 对外按闭店(`state=1`)展示 |
+| 已有订单 | 出餐、配送、支付后续**不受影响** |
+
+**建议**:摊主端遇到 `stall.qualification.approval.required` 文案(或进店页发现未认证)时,引导跳转认证页;认证页根据 4.1 的状态渲染对应操作。
+
+## 6. 错误文案(服务端已本地化,直接展示 msg)
+
+| 场景 | zh-TW 文案示例 |
+|---|---|
+| 版本冲突/已被他人操作 | 資料已變更或已審核,請重新整理後重試 |
+| 缺 version | 請重新整理後再儲存,缺少資料版本 |
+| 待审核中重复提交/修改 | 資料正在審核,暫不可修改或重複提交 |
+| 提交时材料不齐 | 請補齊負責人姓名電話、證件、攤位照片及食品業者登錄資料… |
+| 参数/超长/图片超5张 | 審核資料參數不正確 |
+| 图片无效/不存在/非本摊位 | 審核圖片無效、超過限制或不屬於目前攤位 |
+| 上传处理失败 | 審核材料檔案處理失敗,請稍後重試 |
+| 营业门禁 | 攤位材料尚未審核通過,暫不能營業或建立新訂單 |
+| 非本摊位/账号无效 | (merchant.store.access.denied 对应文案) |
+
+## 7. 建议页面流程
+
+```
+进入认证页
+  └─ GET /stall/qualification
+      ├─ currentApplication = null        → 空表单(version=0),填完可存草稿/提交
+      ├─ status = 0 (草稿)                → 回填表单,继续编辑或提交
+      ├─ status = 1 (待审核)              → 只读展示材料 + "审核中"
+      ├─ status = 3 (驳回)                → 展示 rejectReason;重填提交走新版本
+      └─ status = 2 (通过)                → 展示有效资料;approvedApplication 为当前有效版本
+```
+
+- 图片选择后先 `POST /utils/Upload`,成功再把返回路径塞进对应数组;**上传中禁止点提交**。
+- 待审核状态下隐藏保存/提交按钮(服务端同样会拦)。
+- 提交成功后用响应刷新整个视图和本地 `version`。
+
+## 8. 夜市主进度(可选,type=3 账号)
+
+```http
+GET /stall/qualification/market
+token: <夜市主登录令牌>
+```
+
+返回本人旗下摊位数组:`{ storeId, status(-1=未创建), approved, ... }`,**不含**姓名、证件号、电话和附件。App 若不做夜市主进度页可忽略。
+
+## 9. 注意事项
+
+- `userType` 对照:`0` 用户、`1` 商家、`2` 骑手、`3` 夜市主、`4` 摊主。本功能仅 `4`。
+- 平台审核接口(`/system/stallQualification*`)走平台管理端 Authorization 登录,与 App 无关,不要在 App 调用。
+- 提交后的材料**不可变**:想改只能等驳回后重提,或(已通过后)提交新版本申请;已通过资质在新申请审核期间保持有效。
+- 证件图片链接为公开访问(用户已确认);但姓名、证件号、电话不出现在任何列表/进度接口,App 同样不要在日志里打印这些字段。

+ 171 - 0
docs/store-menu-copy-app-api.md

@@ -0,0 +1,171 @@
+# 连锁门店复制商品(026):App 端接入说明
+
+**日期**:2026-10-12
+**分支**:`test-202609v2`(后端已实现并过测试;商家 PC 端 foodie-store 已上线同款功能,本文档供 App 端复用同一组接口)
+**一句话**:连锁商家在 App 里把一家门店的全部分类+商品**复制**到另一家门店;复制前可**预览差异**,同名但内容不同的商品由商家选择**保留本店版本**或**用源店覆盖**;可重复执行(幂等)。
+
+**功能规格**:`specs/026-store-menu-copy/spec.md`;PC 端参考实现:`foodie-store/src/components/StoreMenuCopyDialog.vue`。
+
+---
+
+## 1. 功能与角色
+
+- 面向**多家门店的商家**:主账号(`userType=1`)可对自己名下所有门店操作;分管账号(022 子账号)仅当源、目标两店都在其授权范围内可用。
+- 摊位商家(`userType=4`)只有一个绑定摊位,**不出现**复制入口。
+- 核心价值:新开门店初始化菜单、向其他门店推新品/改价,替代逐条录入。
+- 本功能**零 DDL、无同步状态表**:差异一律实时计算,预览差异为 0 即等价「已是最新」。
+
+## 2. 通用约定
+
+| 项 | 说明 |
+|---|---|
+| 鉴权 | 请求头 `token: <商家登录令牌>`;服务端校验操作者对源、目标两店**均有权限**,越权返回错误且不产生任何写入 |
+| 响应包裹 | `{ "code": 200, "msg": "...", "data": ... }`;业务失败 `code=500`,`msg` 为服务端本地化文案 |
+| 错误文案语言 | 请求带查询参数 `lang`(如 `lang=vi` / `zh` / `en`,缺省繁中),**App 直接展示 msg,不要自己映射错误文案** |
+| 超时 | 预览与执行建议设 **≥ 15 秒**(PC 端即 15s;后端 100 商品规模 < 10 秒) |
+| 入口控制 | 当前账号可访问门店数 < 2 时,App 不显示「复制商品」入口;源店与目标店不能是同一家 |
+
+## 3. 前置接口:门店列表
+
+选择源门店/展示入口前,先取当前账号有权限的门店:
+
+```http
+GET /chanting/store/storelistlist?page=1&size=100
+token: <商家登录令牌>
+```
+
+- 返回 `data` 为分页对象 `IPage<PosStore>`,App 主要用 `records[].id`(门店 id)和 `records[].posName`(门店名);`total` 判断门店数是否 ≥ 2。
+- 返回范围:主账号 = 名下全部门店;分管账号 = 授权范围内门店;摊位主 = 仅绑定摊位(1 家,无入口)。
+
+## 4. 接口:复制预览
+
+```http
+POST /chanting/food/menu-copy/preview
+token: <商家登录令牌>
+Content-Type: application/json
+
+{
+  "sourceStoreId": 101,
+  "targetStoreId": 202
+}
+```
+
+响应 `data`(`StoreMenuCopyPreviewResponse`):
+
+```json
+{
+  "code": 200,
+  "data": {
+    "sourceStoreId": 101,
+    "targetStoreId": 202,
+    "addedCategoryCount": 5,
+    "mergedCategoryCount": 2,
+    "addedProductCount": 12,
+    "identicalProductCount": 23,
+    "differentProductCount": 3,
+    "differences": [
+      {
+        "sourceProductId": 5001,
+        "targetProductId": 6102,
+        "name": "麦辣鸡腿堡",
+        "language": "3",
+        "fields": [
+          { "field": "price", "sourceValue": "75", "targetValue": "65" }
+        ]
+      }
+    ]
+  }
+}
+```
+
+**计数含义**:
+
+| 字段 | 含义 |
+|---|---|
+| `addedCategoryCount` | 将新建的分类数 |
+| `mergedCategoryCount` | 同名分类合并数(目标店已有,复用) |
+| `addedProductCount` | 将新增的商品数 |
+| `identicalProductCount` | 同名且内容一致、将跳过的商品数 |
+| `differentProductCount` | 同名但内容不同的商品数(差异明细见 `differences`) |
+
+**差异明细 `differences[]`**:每个差异商品含 `sourceProductId` / `targetProductId` / `name` / `language`(多语言行各自独立匹配),`fields[]` 为字段级前后值。`field` 取值固定为以下 7 个之一:
+
+| `field` | 含义 | 前端展示建议 |
+|---|---|---|
+| `price` | 价格 | 金额 |
+| `image` | 图片 | 缩略图对比(URL 引用,直接展示) |
+| `introduce` | 介绍 | 文本,过长截断 |
+| `foodSku` | 属性(SKU) | 原始串,建议只提示「有变化」 |
+| `sort` | 排序 | 数字 |
+| `recommend` | 推荐 | 0/1 |
+| `specs` | 规格关联集合 | 建议只提示「有变化」 |
+
+**判定口径**:分类按(分类名+语言)匹配;商品按(商品名+语言)匹配;内容比对字段即上表 7 项。
+
+## 5. 接口:复制执行
+
+```http
+POST /chanting/food/menu-copy/execute
+token: <商家登录令牌>
+Content-Type: application/json
+
+{
+  "sourceStoreId": 101,
+  "targetStoreId": 202,
+  "conflictStrategy": "KEEP_TARGET"
+}
+```
+
+- `conflictStrategy` 差异商品处理方式:
+  - `KEEP_TARGET`:保留本店版本(**默认**,传空/不传即此值)
+  - `OVERWRITE_TARGET`:用源店版本覆盖差异商品的内容字段
+  - 大小写不敏感;其他值返回错误
+- 执行为**单事务**:全部成功或全部回滚;**幂等**:同参数重复执行,第二次新增 0、覆盖 0。
+
+响应 `data`(`StoreMenuCopyResult`):
+
+```json
+{
+  "code": 200,
+  "data": {
+    "addedCategoryCount": 5,
+    "addedProductCount": 12,
+    "skippedProductCount": 23,
+    "overwrittenProductCount": 0
+  }
+}
+```
+
+## 6. 错误响应
+
+`code=500`,`msg` 为按 `lang` 下发的本地化文案(App 直接展示)。可能的 key 与繁中文案:
+
+| msg key | 繁中文案 | 触发场景 |
+|---|---|---|
+| `menu.copy.store.required` | 來源門店和目標門店不能為空 | 缺 sourceStoreId / targetStoreId |
+| `menu.copy.store.same` | 來源門店和目標門店不能相同 | 两店相同 |
+| `menu.copy.strategy.invalid` | 差異商品處理方式無效 | conflictStrategy 非法值 |
+| `merchant.store.access.denied` | 無權存取該店鋪 | 任一店无权限 / 非商家账号 |
+
+## 7. 业务规则要点(App 交互需对齐)
+
+- **覆盖不破坏本店状态**:`OVERWRITE_TARGET` 只更新内容字段(价格、图片、介绍、属性、排序、推荐、规格关联),**不改**目标店商品的 id、上架状态、审核状态——目标店临时下架的商品覆盖后仍保持下架。
+- **新增商品**继承源店的上架与审核状态,复制后可直接被顾客下单;图片直接引用源店 URL,不复制文件。
+- **规格**:目标店缺模板时按(规格名+语言)新建模板与规格值;已存在的规格模板自身的规格值**不会被修改**(门店内共享资产)。
+- **多语言**:同一商品各语言行独立匹配、独立复制,不做跨语言合并。
+- **预览与执行之间存在秒级时间窗**:若期间菜单被改动,以执行时快照为准,结果计数可能与预览略有出入(单商家操作场景可接受)。
+
+## 8. 推荐 App 交互流程(对齐 PC 端)
+
+1. 商品管理页当前门店即**目标店**;门店数 < 2 隐藏「复制商品」入口。
+2. 弹窗选择**源门店**(选项排除目标店)→ 调预览接口。
+3. 展示 5 个计数;`differentProductCount > 0` 时可展开差异明细(商品名 + 字段前后值),并提供单选:
+   - 保留本店版本(`KEEP_TARGET`,默认)
+   - 用源店覆盖(`OVERWRITE_TARGET`,选中时给出醒目确认提示)
+4. 无可复制内容(新增分类=0、新增商品=0,且未选覆盖)时,执行按钮置灰。
+5. 确认执行 → 展示结果 4 计数(新增分类/新增商品/跳过/覆盖)→ 刷新商品列表。
+6. 幂等可重跑:再次执行全部命中跳过,可当「同步校验」用。
+
+## 9. App 自身多语言
+
+App 端功能 UI 文案需覆盖 **vi / zh / tw / en** 四语。PC 端 `menuCopy.*` 文案 key 可直接参考 `foodie-store/src/lang/{zh,tw,en,vi}.js` 的同名节点。

+ 66 - 0
docs/superpowers/plans/2026-09-17-flash-delivery-v1-v2-merge.md

@@ -0,0 +1,66 @@
+# Flash Delivery v1-v2 Merge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Merge the flash-delivery, rider-capacity and merchant-list work from `test-202609v1` into `test-202609v2` without losing v2 payment-gate or night-market self-delivery behavior.
+
+**Architecture:** Keep the v1 flash-delivery application service as the fulfillment baseline because it owns multi-pickup stops, vehicle snapshots, capacity checks and route fallback. Reapply v2 payment-gate dependencies and behavior to that baseline, then merge DTO, controller, localization and tests so rider new-task filtering applies both vehicle and accepted payment type.
+
+**Tech Stack:** Java 21, Spring Boot, MyBatis, JUnit 5, Mockito, MySQL migration documentation.
+
+**Spec:** `specs/024-flash-delivery/spec.md`, `specs/031-payment-method-config/spec.md`
+
+## Global Constraints
+
+- Do not execute database SQL; record migrations only in `updatesql/sql.md`.
+- Preserve v2 deletion of `messages_vi.properties`; do not reintroduce Vietnamese resources.
+- Preserve existing night-market self-delivery behavior and payment-method gate behavior.
+- Run affected flash-delivery and payment-gate tests with JDK 21 before committing.
+
+---
+
+### Task 1: Resolve production conflicts
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java`
+
+**Interfaces:**
+- Consumes: `PaymentMethodGateService`, `RiderDeliveryAcceptanceService`, vehicle snapshots and multi-pickup stop services.
+- Produces: vehicle-and-payment filtered rider new-task queries, payment-gated flash creation and idempotent rider payment confirmation.
+
+- [ ] Keep v1 multi-pickup, vehicle route/pricing, estimated-duration fallback and rider-capacity calls.
+- [ ] Add v2 `PaymentMethodGateService` dependency, home/quote payment options, payment validation, rider accepted-payment filtering and `confirmPayment` behavior.
+- [ ] Combine `newTask` query constraints using both `vehicle_type` and accepted `pay_type` values.
+
+### Task 2: Resolve contract, localization and test conflicts
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages*.properties`
+- Delete: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java`
+
+**Interfaces:**
+- Consumes: merged production APIs from Task 1.
+- Produces: tests asserting both vehicle and payment behavior, while maintaining v2 language policy.
+
+- [ ] Retain v2 payment and self-delivery messages plus v1 vehicle/capacity messages in supported resource bundles.
+- [ ] Keep `messages_vi.properties` deleted because v2 intentionally removed Vietnamese support.
+- [ ] Update test fixtures for the merged application-service constructor and assert neither payment filtering nor vehicle filtering is dropped.
+
+### Task 3: Verify and integrate
+
+**Files:**
+- Modify: `specs/024-flash-delivery/tasks.md`
+- Modify: `updatesql/sql.md` only if merge resolution requires preserving both documented migrations.
+
+**Interfaces:**
+- Consumes: merged production and test sources.
+- Produces: one merge commit on `test-202609v2`.
+
+- [ ] Verify no conflict markers remain and that both self-delivery DDL and vehicle DDL remain documented.
+- [ ] Run JDK 21 targeted flash-delivery and payment-method tests, then `git diff --check`.
+- [ ] Review staged paths, commit the merge, and report the SHA plus unexecuted external integration checks.

+ 38 - 0
docs/twqr-offline-transfer-qr.md

@@ -0,0 +1,38 @@
+# 闪送线下转账 × 二维码方案讨论记录(2026-09-23)
+
+## 背景
+
+闪送线下转账(payType=6)功能:骑手可维护银行卡(031 已有 `/system/bankCard` 商家/骑手通用接口),
+订单送达后向寄件人展示骑手启用卡三要素(户名/银行名/账号),用户线下转账结清,骑手
+`confirmPayment` 确认收款(payment_status→1)。方案已定 A:到付与线下转账保持两个独立支付方式,
+卡信息仅对 payType=6 的已送达单展示。
+
+讨论主题:能否把银行卡信息包成二维码给用户扫码转账。
+
+## 台湾二维码互通现状
+
+- **TWQR(臺灣 QR Code 共通支付標準)**:财金公司偕同各银行推动的共通机制,基于 EMVCo
+  QRCPS 规范(标签-长度-值 + CRC16)。同一张码可被所有支援的银行 App(台湾 Pay 体系涵盖
+  39 家银行)与电子支付 App(街口、全支付、悠游付等)扫描,跨行通用,支持缴费/转账/消费。
+- **个人转账 QR**:各银行 App 自家"收款码"格式不统一;普通文本(银行代号+账号)编成的
+  非标准码只有部分银行 App 的"扫码转账"能解析,无 100% 保证。
+- TWQR 收款码申请主体目前**多为商家/特店**,个人骑手大概率申请不到。
+
+## 讨论结论
+
+1. **格式层面可行**:二维码本质是包装信息,拿到 TWQR 规格书或足够样例即可按规范生成字符串,
+   任意 QR 库渲染成图——生成不是难点。
+2. **清算层面是真门槛**:码指向的收款账号必须先在财金/银行体系注册 TWQR 收款资格,
+   否则格式正确也收不到钱。个人收款走不通 TWQR。
+3. **产品路径**:
+   - 保底:显示文字卡信息(银行名/账号/户名)——台湾跨行转账 100% 通用
+   - 增强(可选):① App 前端本地编码二维码(通用性不保证);② 骑手上传银行 App 生成的
+     收款码截图(银行自生成的码格式保证,送达后与卡信息一起展示)
+4. **工程结论**:后端只需返回 `bankName/accountNo/accountName` 三要素(已实现于
+   `FlashDeliveryRiderBankCardView`),二维码生成放在 App 端做——格式迭代不用动后端。
+
+## 参考
+
+- [Dacard - TWQR 是什麼?怎麼用?](https://dacard.tw)
+- [PayMe.tw - TWQR 台灣共用支付碼](https://payme.tw)
+- [玉山銀行 - 行動銀行付款碼(台灣Pay/TWQR)使用說明](https://www.esunbank.com.tw)

+ 90 - 0
docs/user-push-voice-app-integration.md

@@ -0,0 +1,90 @@
+# 用户 App 推送语音:前端接入说明
+
+**日期**: 2026-10-09
+**一句话**: 用户订单推送的 payload 里新增了 `eventScene` 字段,App 在前台收到通知时按这个字段播一句短语音。
+
+---
+
+## 1. 从哪里读
+
+| 通道 | 读哪里 |
+|------|--------|
+| uniPush(当前在用) | 消息的 `payload`,可能是 JSON 字符串或对象 |
+| FCM | `data.payload`(JSON 字符串) |
+| APNs | `userInfo.payload`(JSON 字符串) |
+
+payload 长这样:
+
+```json
+{
+  "ddId": "DD1700000001",
+  "state": "1",
+  "type": -1,
+  "pushType": 1,
+  "eventScene": "RIDER_ACCEPTED"
+}
+```
+
+| 字段 | 用途 |
+|------|------|
+| `ddId` | 订单号 |
+| `pushType` | 点击跳转用:1 外卖、2 充值、3 闪送 |
+| `eventScene` | **本次新增**,播语音只看它;没有这个字段就不播 |
+
+不要用 `state`、`type` 判断场景(历史含义不统一)。
+
+## 2. eventScene 对应表
+
+按 **App 自己的语言设置**选音频(payload 里没有语言字段)。音频自己录制,打进 App 包,每个场景一句短话。
+
+| eventScene | 含义 | 简体中文 | 繁體中文 | English | ไทย |
+|------------|------|---------|---------|---------|-----|
+| `RIDER_ACCEPTED` | 骑手接单 | 骑手已接单 | 騎手已接單 | Rider accepted your order | ไรเดอร์รับออเดอร์แล้ว |
+| `ORDER_DELIVERING` | 开始配送 | 您的订单已派送 | 您的訂單已派送 | Your order is on the way | ออเดอร์ของคุณกำลังจัดส่ง |
+| `ORDER_DELIVERED` | 已送达 | 您的订单已送达 | 您的訂單已送達 | Your order has been delivered | จัดส่งออเดอร์ของคุณแล้ว |
+| `MERCHANT_ACCEPTED` | 商家接单 | 商家已接单 | 商家已接單 | The merchant accepted your order | ร้านค้ารับออเดอร์แล้ว |
+| `MERCHANT_READY` | 商家出餐 | 商家已出餐 | 商家已出餐 | Your order is ready | ร้านค้าเตรียมอาหารเสร็จแล้ว |
+| `PAYMENT_SUCCEEDED` | 支付成功 | 您的订单已支付 | 您的訂單已支付 | Payment successful | ชำระเงินสำเร็จ |
+| `ORDER_CANCELLED` | 订单取消 | 您的订单已取消 | 您的訂單已取消 | Your order has been cancelled | ยกเลิกออเดอร์ของคุณแล้ว |
+
+## 3. 播放逻辑(示例)
+
+```js
+// 收到通知回调里(uniPush 2.0 示例)
+uni.onPushMessage((res) => {
+  if (res.type !== 'receive') return;   // 点击通知不播
+  let p = res.data && res.data.payload;
+  if (typeof p === 'string') {
+    try { p = JSON.parse(p); } catch (e) { return; }
+  }
+  playVoice(p && p.eventScene);
+});
+
+function playVoice(scene) {
+  if (!scene || !isForeground() || !isLogin()) return;  // 只在前台且已登录时播
+  const audio = uni.createInnerAudioContext();
+  audio.src = `/static/voice/${scene}_${uni.getLocale()}.mp3`;  // 如 rider_accepted_zh-CN.mp3
+  audio.play();
+}
+```
+
+多笔订单连续通知时用一个播放器排队串行播,不要叠音。
+
+## 4. 规则(就这几条)
+
+1. **只在前台播**;后台/锁屏走系统通知原样,不播语音。
+2. **没有 `eventScene` 不播**(旧通知都这样),JSON 解析失败也不播、不崩溃。
+3. **只播一次**:点击通知、打开消息列表、恢复 App 都不补播。
+4. 音频只用 App 内本地文件,不播订单号等额外信息;播放失败就只留文字。
+
+## 5. 点击跳转
+
+| pushType | 跳转 |
+|----------|------|
+| 1 | 外卖订单详情 |
+| 2 | 充值/钱包页 |
+| 3 | 闪送订单详情(闪送单号别调外卖查询接口) |
+
+---
+
+**联调备注**: 后端字段已实现但未部署;uniPush 通道如果真机上收不到 `eventScene`,反馈后端排查,不要自行改云函数。详细规则见 `specs/035-user-notification-voice/frontend-integration.md`。

+ 64 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -1,11 +1,15 @@
 package com.ruoyi.app.flashdelivery.controller;
 
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopDeliverRequest;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
@@ -41,6 +45,22 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.riderOrders(userId(token), page, size, tab, longitude, latitude));
     }
 
+    /**
+     * 骑手端任务角标数量:一次返回闪送订单 newTask/toPickup/delivering 三个页签的计数,
+     * App 无需逐页签切换请求;newTask 距离过滤口径与列表一致。
+     *
+     * @param token 登录令牌,用于解析当前骑手 ID
+     * @param longitude 骑手当前经度;选填,传入时必须与纬度同时提供
+     * @param latitude 骑手当前纬度;选填,传入时必须与经度同时提供
+     */
+    @GetMapping("/orderCount")
+    @Auth
+    public AjaxResult orderCount(@RequestHeader String token,
+                                 @RequestParam(required = false) BigDecimal longitude,
+                                 @RequestParam(required = false) BigDecimal latitude) {
+        return success(service.riderOrderCounts(userId(token), longitude, latitude));
+    }
+
     /** 接单前隐藏敏感字段;接单后仅中单骑手可见完整履约信息。 */
     @GetMapping("/orders/{id}")
     @Auth
@@ -62,7 +82,15 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.accept(userId(token), id));
     }
 
-    /** 上传取件凭证并将已接单订单推进为已取件。 */
+    /** 骑手确认实际收款;仅首次条件更新写入审计日志,重复调用幂等成功。 */
+    @PostMapping("/orders/{id}/confirmPayment")
+    @Auth
+    public AjaxResult confirmPayment(@RequestHeader String token, @PathVariable Long id) {
+        service.confirmPayment(userId(token), id);
+        return success();
+    }
+
+    /** 上传指定取货站凭证;全部取货站完成后订单才进入已取件。 */
     @PostMapping("/orders/{id}/pickup")
     @Auth
     public AjaxResult pickup(@RequestHeader String token, @PathVariable Long id,
@@ -72,6 +100,7 @@ public class FlashDeliveryRiderController extends BaseController {
     }
 
     /** 上传送达凭证并将已取件订单推进为已送达。 */
+    @Deprecated
     @PostMapping("/orders/{id}/deliver")
     @Auth
     public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
@@ -80,5 +109,39 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
+    /** v2 逐站送达:业务参数统一放 body,全部收货站完成订单才进入已送达。 */
+    @PostMapping("/orders/stops/deliver")
+    @Auth
+    public AjaxResult deliverAtStop(@RequestHeader String token,
+                                    @RequestBody FlashDeliveryStopDeliverRequest request) {
+        if (request == null) throw new ServiceException(MessageUtils.message("flash.delivery.request.required"));
+        if (request.getOrderId() == null || request.getOrderId() <= 0) {
+            throw new ServiceException(MessageUtils.message("flash.delivery.order.not.found"));
+        }
+        if (request.getStopId() == null || request.getStopId() <= 0) {
+            throw new ServiceException(MessageUtils.message("flash.delivery.stop.id.required"));
+        }
+        service.deliverAtStop(userId(token), request.getOrderId(), request.getStopId(), request);
+        return success();
+    }
+
+    /** 只确认指定站点取货,不自动选择其他待取站点。 */
+    @PostMapping("/orders/{id}/stops/{stopId}/pickup")
+    @Auth
+    public AjaxResult pickupStop(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,
+                                 @RequestBody FlashDeliveryProofRequest request) {
+        service.pickupAtStop(userId(token), id, stopId, request);
+        return success();
+    }
+
+    /** 兼容旧客户端的可选导航目标;新客户端直接按 stopId 确认取送。 */
+    @Deprecated
+    @PostMapping("/orders/{id}/stops/{stopId}/target")
+    @Auth
+    public AjaxResult target(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,
+                             @RequestBody FlashDeliveryTargetRequest request) {
+        return success(service.selectTarget(userId(token), id, stopId, request));
+    }
+
     private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
 }

+ 43 - 15
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -24,7 +24,8 @@ public class FlashDeliveryUserController extends BaseController {
     @Auth
     public AjaxResult home(@RequestHeader String token) { return success(service.home()); }
 
-    /** 根据取送坐标获取服务端实时报价。 */
+    /** 根据取送坐标获取服务端实时报价;v2 上线起废弃,App 切换后随退役批次删除。 */
+    @Deprecated
     @PostMapping("/quote")
     @Auth
     public AjaxResult quote(@RequestHeader String token, @RequestBody FlashDeliveryQuoteRequest request) {
@@ -32,7 +33,19 @@ public class FlashDeliveryUserController extends BaseController {
         return success(service.quote(request));
     }
 
+    /**
+     * v2 多点取送实时报价:取货点与收货点统一放在 stops 数组,顺序即路线顺序;
+     * 物品主体整单一次设置。旧 /quote 过渡期保留,见契约废弃说明。
+     */
+    @PostMapping("/v2/quote")
+    @Auth
+    public AjaxResult quoteV2(@RequestHeader String token, @RequestBody FlashDeliveryV2QuoteRequest request) {
+        userId(token);
+        return success(service.quoteV2(request));
+    }
+
     /** 幂等创建闪送订单,客户端不能指定用户、距离或金额。 */
+    @Deprecated
     @PostMapping("/orders")
     @Auth
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
@@ -44,21 +57,16 @@ public class FlashDeliveryUserController extends BaseController {
         }
     }
 
-    /**
-     * 分页查询当前用户参与的闪送订单。
-     *
-     * @param token 登录令牌,用于解析当前用户 ID
-     * @param page 页码,从 1 开始,默认 1
-     * @param size 每页数量,默认 10,服务端限制为 1 至 100
-     * @param role 订单角色;sender 表示我发的,receiver 表示我收的,不传按 sender 处理
-     */
-    @GetMapping("/orders")
+    /** v2 幂等创建多点取送订单;旧 /orders 过渡期保留,全部功能调整完成后随退役批次删除。 */
+    @PostMapping("/v2/orders")
     @Auth
-    public AjaxResult orders(@RequestHeader String token,
-                             @RequestParam(defaultValue = "1") int page,
-                             @RequestParam(defaultValue = "10") int size,
-                             @RequestParam(required = false) String role) {
-        return success(service.userOrders(userId(token), page, size, role));
+    public AjaxResult createV2(@RequestHeader String token, @RequestBody FlashDeliveryV2CreateRequest request) {
+        try {
+            return success(service.createV2(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
     }
 
     /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */
@@ -96,6 +104,26 @@ public class FlashDeliveryUserController extends BaseController {
         }
     }
 
+    /** v2 修改完整站点的预报价,沿用订单原有物品与运价快照。 */
+    @PostMapping("/v2/orders/address/quote")
+    @Auth
+    public AjaxResult quoteAddressV2(@RequestHeader String token,
+                                     @RequestBody FlashDeliveryV2AddressChangeRequest request) {
+        return success(service.quoteAddressV2(userId(token), request));
+    }
+
+    /** v2 确认完整站点改址,订单和站点 ID 均通过 body 接收。 */
+    @PostMapping("/v2/orders/address")
+    @Auth
+    public AjaxResult updateAddressV2(@RequestHeader String token,
+                                      @RequestBody FlashDeliveryV2AddressConfirmRequest request) {
+        try {
+            return success(service.updateAddressV2(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"), exception.getLatestQuote());
+        }
+    }
+
     /** 待接单时追加正整数小费,同步增加订单总金额。 */
     @PostMapping("/orders/tip")
     @Auth

+ 5 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java

@@ -1,14 +1,17 @@
 package com.ruoyi.app.flashdelivery.dto;
 
+import java.util.List;
 import lombok.Data;
 
-/** 待接单订单修改取送信息;两端均提交完整地址,未修改的一端原样回传。 */
+/** 待接单订单修改取送信息;多点提交全部pickups,单点可用旧pickup并保留该站物品。 */
 @Data
 public class FlashDeliveryAddressChangeRequest {
     /** 用户订单详情或地址报价返回的订单版本,必填。 */
     private Integer orderVersion;
-    /** 新取件地址,需完整提交,未修改时也原样回传。 */
+    /** 仅用于单点订单的新取件地址,与pickups数组二选一。 */
     private FlashDeliveryAddressRequest pickup;
     /** 新收件地址,需完整提交,未修改时也原样回传。 */
     private FlashDeliveryAddressRequest delivery;
+    /** 按用户顺序提交完整取货点及物品,不能与旧pickup字段同时出现。 */
+    private List<FlashDeliveryPickupRequest> pickups;
 }

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

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderLog;
+import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
 import lombok.Data;
 
 import java.util.List;
@@ -16,4 +17,21 @@ public class FlashDeliveryAdminOrderDetailView {
     private List<FlashDeliveryOrderImage> images;
     /** 状态变更日志。 */
     private List<FlashDeliveryOrderLog> logs;
+    /** 取货站总数及已实际确认的取货站数。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
+    /** 服务端按最新站点事实计算;缺站点或任一站已取货时为 false。 */
+    private Boolean canAdminCancel;
+    /** LINE Pay 本地资金事实,不含支付链接及密钥。 */
+    private FlashLinePayPaymentView linePayPayment;
+    /** 按用户原始顺序返回站点、独立物品及本站凭证。 */
+    private List<FlashDeliveryStopView> stops = List.of();
+    /** 当前前往目标,全部取货后指向唯一收货站。 */
+    private Long currentTargetStopId;
+    /** 目标调整记录,骑手声明与实际取货凭证分别展示。 */
+    private List<FlashDeliveryTargetAdjustment> targetAdjustments = List.of();
+    /** 平台异常处理中的交接凭证,独立于骑手取货凭证。 */
+    private List<String> handoffImageUrls = List.of();
 }

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java

@@ -13,4 +13,16 @@ public class FlashDeliveryHomeView {
     private List<String> deliveryTypes;
     /** 当前时段各服务的运价摘要,无可用运价时为空列表。 */
     private List<FlashDeliveryServiceView> services;
+    /** 闪送维度可用支付方式,供用户端下单页展示。 */
+    private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
+    /** 取货点有效上限,合法范围1至10,配置非法时为1。 */
+    private Integer pickupStopLimit;
+    /** Compatibility field: true when pickupStopLimit is greater than 1. */
+    private Boolean showMultiPickup;
+    /** v2 收货点有效上限,合法范围1至10,配置非法时为1。 */
+    private Integer deliveryStopLimit;
+    /** Compatibility field: true when deliveryStopLimit is greater than 1. */
+    private Boolean showMultiDropoff;
+    /** Compatibility field: urgent service is always available. */
+    private Boolean showUrgentOption;
 }

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.flashdelivery.dto;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
 import com.ruoyi.system.domain.InfoUser;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
@@ -11,7 +12,7 @@ import java.util.List;
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
-    /** 用户编辑待接单订单使用的版本;骑手详情不返回。 */
+    /** 用户编辑及中单骑手选择目标使用的版本;接单前不返回。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private Integer orderVersion;
     /** 寄件图片 URL 列表,用户下单时上传,可为空。 */
@@ -32,4 +33,13 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     /** 交付 PIN,仅启用 PIN 的用户详情返回,骑手端始终为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private String deliveryPinCode;
+    /** 按用户原始顺序返回取送站点、本站备注及凭证;v2 允许取送交错。 */
+    private List<FlashDeliveryStopView> stops = List.of();
+    /** 当前前往目标;v2 为原序最早未完成站或骑手自主选定站,不由 Tab 主状态推导。 */
+    private Long currentTargetStopId;
+    /** 目标调整记录,骑手声明与实际取货凭证分别展示。 */
+    private List<FlashDeliveryTargetAdjustment> targetAdjustments = List.of();
+    /** 骑手启用收款卡三要素;仅寄件人查看线下转账单且已送达/已完成时返回。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private FlashDeliveryRiderBankCardView riderBankCard;
 }

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

@@ -59,6 +59,8 @@ public class FlashDeliveryOrderView {
     private String currency;
     /** 支付方式:4=现金、6=线下转账;款项直达骑手,用户与骑手详情均返回。 */
     private String payType;
+    /** 收款状态:0=未收款,1=骑手已确认收款。 */
+    private Integer paymentStatus;
     /** 用户备注,骑手接单前不返回。 */
     private String userNote;
     /** 骑手接单时间。 */
@@ -77,4 +79,12 @@ public class FlashDeliveryOrderView {
     private Date createTime;
     /** 最后更新时间。 */
     private Date updateTime;
+    /** 取货站总数及已取数;与送达进度独立,未全部取完仍为 ACCEPTED。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;交错取送时 ACCEPTED 也可能有非零已送达数。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
 }

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPickupRequest.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 完整取货地址及独立物品信息,数组顺序决定报价路线与默认取件顺序。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryPickupRequest extends FlashDeliveryAddressRequest {
+    /** 本站独立申报的物品类别。 */
+    private String packageType;
+    /** 本站物品数量,必须为正整数。 */
+    @JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
+    private Integer quantity;
+    /** 本站物品重量范围,不代表整单重量。 */
+    private String weightRange;
+    /** 本站物品尺寸或规格,选填,最长255字符。 */
+    private String specification;
+}

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java

@@ -12,4 +12,6 @@ public class FlashDeliveryProofRequest {
      * 每项最长 1000 个字符,允许相对路径。
      */
     private List<String> imageUrls;
+    /** 取货站点编号;仅单取货点订单允许省略。 */
+    private Long stopId;
 }

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuantityDeserializer.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+import java.io.IOException;
+import java.math.BigInteger;
+
+/** 防止站点数量的小数或超大值被截断;无效输入交由Service统一返回国际化业务校验。 */
+public class FlashDeliveryQuantityDeserializer extends JsonDeserializer<Integer> {
+    @Override
+    public Integer deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+        if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT) {
+            // 无效数组或对象也要完整消耗,确保后续字段不会解析错位。
+            parser.skipChildren();
+            return null;
+        }
+        BigInteger quantity = parser.getBigIntegerValue();
+        return quantity.bitLength() < 32 ? quantity.intValue() : null;
+    }
+}

+ 12 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -1,5 +1,7 @@
 package com.ruoyi.app.flashdelivery.dto;
 
+import java.util.List;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import lombok.Data;
 
 import java.util.Date;
@@ -23,17 +25,18 @@ public class FlashDeliveryQuoteRequest {
     private Integer vehicleType;
 
     /**
-     * 物品类别,必填。
+     * 旧单点输入的物品类别;使用pickups数组时不得提交。
      * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、
      * FOOD_INGREDIENTS、ELECTRONICS、SMALL_APPLIANCE、OTHER。
      */
     private String packageType;
 
-    /** 物品总数量,必填,必须为大于 0 的整数。 */
+    /** 旧单点输入的物品数量,必须为大于0的整数;使用pickups时不得提交。 */
+    @JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
     private Integer quantity;
 
     /**
-     * 物品重量范围,选填。
+     * 旧单点输入的物品重量范围;使用pickups时不得提交。
      * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG。
      */
     private String weightRange;
@@ -62,9 +65,14 @@ public class FlashDeliveryQuoteRequest {
      */
     private Date scheduledPickupEndAt;
 
-    /** 取件联系人、电话、地址、交接方式和经纬度,必填。 */
+    /** 支付方式选填,缺省现金;服务端按闪送支付闸门校验。 */
+    private String payType;
+
+    /** 旧单点取件地址,与pickups数组二选一。 */
     private FlashDeliveryAddressRequest pickup;
 
     /** 收件联系人、电话、地址、交接方式和经纬度,必填。 */
     private FlashDeliveryAddressRequest delivery;
+    /** 按用户顺序提交完整取货点及物品,不能与旧pickup字段同时出现。 */
+    private List<FlashDeliveryPickupRequest> pickups;
 }

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

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 
 import java.math.BigDecimal;
+import java.util.List;
 
 /** 服务端按当前时段运价生成的闪送报价。 */
 @Data
@@ -62,4 +63,6 @@ public class FlashDeliveryQuoteView {
     private String currency;
     /** 运价配置版本,创建订单时需回传。 */
     private Integer pricingVersion;
+    /** 闪送可选支付方式(031 闸门开放集合),与首页同源。 */
+    private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
 }

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.flashdelivery.dto;
 
+import java.util.List;
 import lombok.Data;
 
 /** 用户或平台取消闪送订单时提交的原因。 */
@@ -7,4 +8,12 @@ import lombok.Data;
 public class FlashDeliveryReasonRequest {
     /** 取消原因,必填,去除首尾空格后不能为空且最长 500 个字符。 */
     private String reason;
+    /** 问题站点编号,收件拒收时允许指定DELIVERY收货站。 */
+    private Long problemStopId;
+    /** 平台取消时必填的处理结果,最长500字符。 */
+    private String handlingResult;
+    /** 已取物品去向或接收人;已有取货时必填,最长500字符。 */
+    private String pickedGoodsDisposition;
+    /** 平台介入时的实际交接凭证;已有取货时至少1张、最多9张。 */
+    private List<String> handoffImageUrls;
 }

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderBankCardView.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 送达后线下转账单展示的骑手启用收款卡;仅暴露转账所需三要素,不携带内部字段。 */
+@Data
+public class FlashDeliveryRiderBankCardView {
+    /** 银行名称(台湾银行字典文案)。 */
+    private String bankName;
+    /** 银行账号。 */
+    private String accountNo;
+    /** 户名。 */
+    private String accountName;
+
+    public FlashDeliveryRiderBankCardView(String bankName, String accountNo, String accountName) {
+        this.bankName = bankName;
+        this.accountNo = accountNo;
+        this.accountName = accountName;
+    }
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderCountView.java

@@ -0,0 +1,14 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 骑手端任务角标数量:一次返回三个页签的计数,App 无需逐页签切换请求。 */
+@Data
+public class FlashDeliveryRiderOrderCountView {
+    /** 新任务(可抢任务)数量;骑手未开通闪送时为 0。 */
+    private long newTask;
+    /** 待取件数量。 */
+    private long toPickup;
+    /** 配送中数量。 */
+    private long delivering;
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java

@@ -63,6 +63,20 @@ public class FlashDeliveryRiderOrderListView {
     private String currency;
     /** 支付方式:4=现金、6=线下转账;骑手抢单前需知道如何收钱。 */
     private String payType;
+    /** 收款状态:0=未收款,1=骑手已确认收款。 */
+    private Integer paymentStatus;
     /** 创建时间。 */
     private Date createTime;
+    /** 取货站总数及已取数;与送达进度同时展示,未全部取完仍在待取件 Tab。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;交错取送时 ACCEPTED 也可能已送达,不能按主状态置零。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
+    /** 用户备注;仅骑手本人任务返回,待抢单卡片为空。 */
+    private String userNote;
+    /** v2 多站点单的逐站列表(按用户顺序);卡片只含文字地址与状态,联系人、电话、坐标、凭证见详情。 */
+    private java.util.List<FlashDeliveryStopView> stops;
 }

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopDeliverRequest.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 新逐站送达请求;orderId 与继承的 stopId 均必填,业务参数统一从请求体读取。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryStopDeliverRequest extends FlashDeliveryDeliverRequest {
+    /** 闪送订单 ID,必填正整数;stopId 指定本单的收货站。 */
+    private Long orderId;
+}

+ 31 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java

@@ -0,0 +1,31 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import java.util.List;
+import java.util.Date;
+import java.math.BigDecimal;
+import lombok.Data;
+
+/** 站点详情;骑手接单前逐站隐藏联系人、电话、坐标和凭证。 */
+@Data
+public class FlashDeliveryStopView {
+    private Long id;
+    private String stopType;
+    private Integer stopOrder;
+    private String name;
+    private String phone;
+    private String address;
+    private String addressDetail;
+    private String handoffMethod;
+    private BigDecimal longitude;
+    private BigDecimal latitude;
+    /** 选填备注,告知骑手该站取什么或送什么。 */
+    private String remark;
+    /** 按站收货码,仅发起人或本站绑定收件人可见,骑手不返回。 */
+    @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+    private String deliveryPinCode;
+    private String status;
+    private Date pickedUpAt;
+    private Date deliveredAt;
+    /** 本站实际取货或送达凭证,接单前不向骑手返回。 */
+    private List<String> imageUrls = List.of();
+}

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 选择前往取/送站点,不确认取送、不修改客户原站序或运费;v2 允许骑手自主选择。 */
+@Data
+public class FlashDeliveryTargetRequest {
+    /** 订单详情版本,必填,用于拒绝过期的目标选择请求。 */
+    private Integer orderVersion;
+    /** 本次骑手操作的幂等编号,最长64字符;重试保持编号和内容一致。 */
+    private String clientRequestId;
+    /** 调整原因,最长500字符;v2 可选,旧契约订单改变目标仍必填。 */
+    private String reason;
+    /** 旧契约订单的骑手同意声明;v2 可省略,保留字段用于兼容和记录实际请求。 */
+    private Boolean consentDeclared;
+}

+ 0 - 60
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java

@@ -1,60 +0,0 @@
-package com.ruoyi.app.flashdelivery.dto;
-
-import lombok.Data;
-
-import java.util.Date;
-
-/** 用户端闪送订单卡片,仅包含列表页面所需字段。 */
-@Data
-public class FlashDeliveryUserOrderListView {
-    /** 订单 ID。 */
-    private Long id;
-    /** 订单号。 */
-    private String orderNo;
-    /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
-    private String serviceType;
-    /** 配送等级:NORMAL=普通、URGENT=加急。 */
-    private String deliveryType;
-    /** 配送车型:1=机车、2=轿车。 */
-    private Integer vehicleType;
-    /** 订单状态,取值见 FlashDeliveryStatus。 */
-    private String status;
-    /** 用户编辑待接单订单使用的乐观锁版本。 */
-    private Integer orderVersion;
-    /** 物品类别。 */
-    private String packageType;
-    /** 物品数量。 */
-    private Integer quantity;
-    /** 重量范围。 */
-    private String weightRange;
-    /** 取件方式:NOW=立即、SCHEDULED=预约。 */
-    private String deliveryMode;
-    /** 预约取件时间窗开始时间,立即单为空。 */
-    private Date scheduledPickupStartAt;
-    /** 预约取件时间窗结束时间,立即单为空。 */
-    private Date scheduledPickupEndAt;
-    /** 取件主地址。 */
-    private String pickupAddress;
-    /** 取件详细地址。 */
-    private String pickupDetailAddress;
-    /** 收件主地址。 */
-    private String deliveryAddress;
-    /** 收件详细地址。 */
-    private String deliveryDetailAddress;
-    /** 预计配送时长(秒)。 */
-    private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
-    private Long baseDeliveryFee;
-    /** 加急费(TWD),普通配送为 0。 */
-    private Long urgentFee;
-    /** 小费(TWD)。 */
-    private Long tipAmount;
-    /** 订单总金额(TWD)。 */
-    private Long amount;
-    /** 币种。 */
-    private String currency;
-    /** 送达时间。 */
-    private Date deliveredAt;
-    /** 创建时间。 */
-    private Date createTime;
-}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressChangeRequest.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import java.util.List;
+import lombok.Data;
+
+/** v2 待接单改址:只提交完整站点,订单级物品与运价沿用原订单。 */
+@Data
+public class FlashDeliveryV2AddressChangeRequest {
+    private Long orderId;
+    private Integer orderVersion;
+    private List<FlashDeliveryV2StopRequest> stops;
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressConfirmRequest.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** v2 确认改址时回传最新报价,订单/版本/完整站点均在 body。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2AddressConfirmRequest extends FlashDeliveryV2AddressChangeRequest {
+    private Integer quotedDistanceMeters;
+    private Long quotedBaseDeliveryFee;
+    private Long quotedDistanceFee;
+    private Long quotedUrgentFee;
+    private Long quotedAmount;
+}

+ 41 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2CreateRequest.java

@@ -0,0 +1,41 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/** v2 创建闪送订单请求;服务端按最新条件重新报价,回传报价不一致时拒绝创建。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2CreateRequest extends FlashDeliveryV2QuoteRequest {
+    /** 客户端幂等请求号,必填,最长 64 字符;同一用户重复提交返回首次创建的订单。 */
+    private String clientRequestId;
+
+    /** 报价响应中的运价配置 ID,必填,创建时原样回传。 */
+    private Long pricingId;
+
+    /** 报价响应中的运价版本,必填,创建时原样回传。 */
+    private Integer pricingVersion;
+
+    /** 报价响应中的基础配送费,必填,整数 TWD。 */
+    private Long quotedBaseDeliveryFee;
+
+    /** 报价响应中的距离附加费,必填,整数 TWD。 */
+    private Long quotedDistanceFee;
+
+    /** 报价响应中的加急费,必填,整数 TWD;普通配送为 0。 */
+    private Long quotedUrgentFee;
+
+    /** 报价响应中的订单总金额,必填,整数 TWD。 */
+    private Long quotedAmount;
+
+    /** 是否启用交付收货码,选填;默认 true,启用时按收货站逐站生成。 */
+    private Boolean pinRequired;
+
+    /** 寄件图片地址,选填,最多 9 张。 */
+    private List<String> senderImageUrls;
+
+    /** 用户对本次闪送的备注,选填,最长 500 个字符。 */
+    private String userNote;
+}

+ 57 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2QuoteRequest.java

@@ -0,0 +1,57 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * v2 多点取送报价请求:物品主体整单只设置一次,取货点与收货点统一放在 stops 数组。
+ * 与旧报价结构互斥;旧接口过渡期保留,见 FR-122。
+ */
+@Data
+public class FlashDeliveryV2QuoteRequest {
+    /** 业务场景,必填:HELP_SEND 帮送、HELP_PICKUP 帮取。 */
+    private String serviceType;
+
+    /** 配送等级,必填:NORMAL 普通、URGENT 一对一加急。 */
+    private String deliveryType;
+
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
+
+    /** 物品类别,整单一次;可选值与旧契约一致。 */
+    private String packageType;
+
+    /** 物品数量,整单一次,必须为大于 0 的整数。 */
+    @JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
+    private Integer quantity;
+
+    /** 物品重量范围,整单一次,决定车型运价;可选值与旧契约一致。 */
+    private String weightRange;
+
+    /** 物品体积、尺寸或其他规格说明,选填,最长 255 个字符。 */
+    private String specification;
+
+    /** 骑手小费,必填,整数新台币,没有时传 0。 */
+    private Long tipAmount;
+
+    /** 取件方式,选填,默认 NOW;SCHEDULED 表示预约取件。 */
+    private String deliveryMode;
+
+    /** 预约取件开始时间;SCHEDULED 时必填,NOW 时不得传。 */
+    private Date scheduledPickupStartAt;
+
+    /** 预约取件结束时间;SCHEDULED 时必填且晚于开始 30 分钟,NOW 时不得传。 */
+    private Date scheduledPickupEndAt;
+
+    /** 支付方式选填,缺省现金;服务端按闪送支付闸门校验。 */
+    private String payType;
+
+    /**
+     * 统一站点数组,必填,顺序即用户添加顺序与骑手路线顺序。
+     * 至少 1 个取货站与 1 个收货站,取货站必须全部位于收货站之前。
+     */
+    private List<FlashDeliveryV2StopRequest> stops;
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** v2 统一站点:可交错取送,数组顺序用于客户默认路线与报价,骑手实际目标单独记录。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2StopRequest extends FlashDeliveryAddressRequest {
+    /** 站点类型,必填:PICKUP 取货、DELIVERY 收货;类型是取/送的唯一区别。 */
+    private String type;
+
+    /** 选填备注,去除首尾空格后最长 200 字,告知骑手该站取什么或送什么。 */
+    private String remark;
+}

+ 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));}
+}

+ 69 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/DistanceSourceRouteProvider.java

@@ -0,0 +1,69 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 距离来源开关(032 FR-001):按字典 {@code sys_map_distance_source} 在 Google Routes 与
+ * 自部署地图间逐请求切换,改字典即生效,回滚不发版。仅一个启用项标记 is_default=Y
+ * 时读取其值;缺失、多选或未知值一律按 GOOGLE(默认不切换)。切换仅影响闪送路线距离来源,
+ * 直线兜底与距离来源落库逻辑不变(FR-002)。
+ */
+@Primary
+@Component
+public class DistanceSourceRouteProvider implements RouteDistanceProvider {
+
+    /** 自部署地图开关值(忽略大小写与首尾空白)。 */
+    static final String SELF_HOSTED = "SELF_HOSTED";
+
+    private final GoogleRoutesDistanceProvider googleProvider;
+    private final SelfHostedRouteDistanceProvider selfHostedProvider;
+
+    public DistanceSourceRouteProvider(GoogleRoutesDistanceProvider googleProvider,
+                                       SelfHostedRouteDistanceProvider selfHostedProvider) {
+        this.googleProvider = googleProvider;
+        this.selfHostedProvider = selfHostedProvider;
+    }
+
+    @Override
+    public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                               Integer vehicleType) {
+        return delegate().route(originLatitude, originLongitude, destinationLatitude,
+                destinationLongitude, vehicleType);
+    }
+
+    @Override
+    public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        return delegate().route(points, vehicleType);
+    }
+
+    private RouteDistanceProvider delegate() {
+        return selfHostedEnabled() ? selfHostedProvider : googleProvider;
+    }
+
+    /** 开关判定:唯一默认项的值为 SELF_HOSTED 时启用自部署地图。 */
+    boolean selfHostedEnabled() {
+        List<SysDictData> rows = DictUtils.getDictCache(dictType());
+        if (rows == null) return false;
+        SysDictData selected = null;
+        for (SysDictData row : rows) {
+            if (row == null || row.getIsDefault() == null
+                    || !"Y".equalsIgnoreCase(row.getIsDefault().trim())) continue;
+            if (selected != null) return false;
+            selected = row;
+        }
+        return selected != null && selected.getDictValue() != null
+                && SELF_HOSTED.equalsIgnoreCase(selected.getDictValue().trim());
+    }
+
+    /** 闪送与外卖共用的距离来源开关字典类型。 */
+    static String dictType() {
+        return "sys_map_distance_source";
+    }
+}

+ 62 - 25
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -1,10 +1,15 @@
 package com.ruoyi.app.flashdelivery.route;
 
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
+import java.util.Arrays;
+import java.util.List;
+
 /** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
 @Service
 public class FlashDeliveryRouteService {
@@ -19,24 +24,60 @@ public class FlashDeliveryRouteService {
         this.provider = provider;
     }
 
-    /** 按用户选择车型计算取送路线:优先地图路线,异常或无结果时降级球面直线距离。 */
-    public RouteDistance calculate(GeoPoint origin, GeoPoint destination, Integer vehicleType) {
-        validate(origin);
-        validate(destination);
+    /** 按用户提供的完整站点序列和订单车型计算路线,最后一点为收货点。 */
+    public RouteDistance calculate(java.util.List<GeoPoint> points, Integer vehicleType) {
+        // 数量和所有坐标必须先通过校验,输入错误不能伪装成地图故障降级。
+        validatePoints(points);
+        List<GeoPoint> ordered = List.copyOf(points);
         int selectedVehicle = FlashDeliveryVehicleType.isSupported(vehicleType)
                 ? vehicleType : FlashDeliveryVehicleType.MOTORCYCLE;
         try {
-            RouteDistance route = provider.route(origin.latitude(), origin.longitude(),
-                    destination.latitude(), destination.longitude(), selectedVehicle);
-            if (route != null && route.distanceMeters() > 0) {
-                return route;
-            }
+            RouteDistance route = provider.route(ordered, selectedVehicle);
+            if (route != null && route.distanceMeters() > 0
+                    && (route.durationSeconds() == null || route.durationSeconds() >= 0)) return route;
+        } catch (RouteOutsideRegionException exception) {
+            // 自部署地图判定坐标越界:按 spec 边界场景不降级计价,以国际化提示结束本次请求。
+            throw new ServiceException(MessageUtils.message("flash.delivery.map.outside.region"));
         } catch (RuntimeException exception) {
-            // 不记录地址、坐标或 Key;保留车型和失败原因以定位 Google Routes 降级。
-            log.warn("Google Routes unavailable; use straight-line fallback. vehicleType={}, reason={}",
+            // 不记录地址、坐标或 Key;保留车型和失败原因以定位路线来源降级。
+            log.warn("Route provider unavailable; use straight-line fallback. vehicleType={}, reason={}",
                     selectedVehicle, exception.getMessage());
         }
-        // Haversine 只作为降级结果,并通过 source=STRAIGHT_LINE 明确告知调用方。
+        double meters = 0;
+        for (int i = 1; i < ordered.size(); i++) {
+            meters += straightLineMeters(ordered.get(i - 1), ordered.get(i));
+        }
+        // 同坐标相邻站贡献零距离,只在整线汇总后取整并保留既有最小距离。
+        int straightLineMeters = Math.max(1, Math.toIntExact(Math.round(meters)));
+        return new RouteDistance(straightLineMeters, calculateFallbackDurationSeconds(straightLineMeters, selectedVehicle),
+                "STRAIGHT_LINE");
+    }
+
+    /** 兼容未携带车型的旧调用,历史订单按机车处理。 */
+    public RouteDistance calculate(java.util.List<GeoPoint> points) {
+        return calculate(points, FlashDeliveryVehicleType.MOTORCYCLE);
+    }
+
+    /** 计算取送两点路线距离:优先地图路线,异常或无结果时降级球面直线距离。 */
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination) {
+        return calculate(Arrays.asList(origin, destination));
+    }
+
+    /** 按订单车型计算两点路线,供旧单点入口和新多取货点入口共用。 */
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination, Integer vehicleType) {
+        return calculate(Arrays.asList(origin, destination), vehicleType);
+    }
+
+    /** 地图路线不可用时,按车型保守平均速度估算履约展示时长;该值不参与闪送计价。 */
+    private int calculateFallbackDurationSeconds(int distanceMeters, int vehicleType) {
+        int speedKph = vehicleType == FlashDeliveryVehicleType.CAR
+                ? CAR_FALLBACK_SPEED_KPH : MOTORCYCLE_FALLBACK_SPEED_KPH;
+        double seconds = distanceMeters * 3_600D / (speedKph * 1_000D);
+        return Math.max(MINIMUM_FALLBACK_DURATION_SECONDS, (int) Math.ceil(seconds));
+    }
+
+    /** Haversine 分段距离只用于地图不可用时的整线降级。 */
+    private double straightLineMeters(GeoPoint origin, GeoPoint destination) {
         double lat1 = Math.toRadians(origin.latitude().doubleValue());
         double lat2 = Math.toRadians(destination.latitude().doubleValue());
         double deltaLat = lat2 - lat1;
@@ -45,23 +86,19 @@ public class FlashDeliveryRouteService {
         double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
                 + Math.cos(lat1) * Math.cos(lat2)
                 * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
-        int meters = (int) Math.round(EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a)));
-        int straightLineMeters = Math.max(1, meters);
-        return new RouteDistance(straightLineMeters, calculateFallbackDurationSeconds(straightLineMeters, selectedVehicle),
-                "STRAIGHT_LINE");
+        a = Math.max(0D, Math.min(1D, a));
+        return EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a));
     }
 
-    /**
-     * 地图路线不可用时,按车型保守平均速度估算履约展示时长;该值不参与闪送计价。
-     */
-    private int calculateFallbackDurationSeconds(int distanceMeters, int vehicleType) {
-        int speedKph = vehicleType == FlashDeliveryVehicleType.CAR
-                ? CAR_FALLBACK_SPEED_KPH : MOTORCYCLE_FALLBACK_SPEED_KPH;
-        double seconds = distanceMeters * 3_600D / (speedKph * 1_000D);
-        return Math.max(MINIMUM_FALLBACK_DURATION_SECONDS, (int) Math.ceil(seconds));
+    /** 26 个取货点加唯一收货点,保持与业务字典上限一致。 */
+    static void validatePoints(List<GeoPoint> points) {
+        if (points == null || points.size() < 2 || points.size() > 27) {
+            throw new IllegalArgumentException("invalid route point count");
+        }
+        points.forEach(FlashDeliveryRouteService::validate);
     }
 
-    private void validate(GeoPoint point) {
+    private static void validate(GeoPoint point) {
         if (point == null || point.latitude() == null || point.longitude() == null
                 || point.latitude().doubleValue() < -90D || point.latitude().doubleValue() > 90D
                 || point.longitude().doubleValue() < -180D || point.longitude().doubleValue() > 180D) {

+ 36 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -32,19 +32,20 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
                                BigDecimal destinationLatitude, BigDecimal destinationLongitude,
                                Integer vehicleType) {
+        return route(List.of(new GeoPoint(originLatitude, originLongitude),
+                new GeoPoint(destinationLatitude, destinationLongitude)), vehicleType);
+    }
+
+    /** 一次请求完整路线,按原始顺序传中途点;不调用路线优化接口。 */
+    @Override
+    public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        JSONObject body = requestBody(points, vehicleType);
         // 闪送与外卖共用既有地图 Key,避免维护第二套字典配置。
         List<SysDictData> keys = DictUtils.getDictCache(mapKeyDictType());
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
                 || keys.get(0).getDictValue().isBlank()) {
             throw new IllegalStateException("google routes key unavailable");
         }
-        JSONObject body = new JSONObject();
-        body.put("origin", waypoint(originLatitude, originLongitude));
-        body.put("destination", waypoint(destinationLatitude, destinationLongitude));
-        body.put("travelMode", travelModeFor(vehicleType));
-        body.put("routingPreference", "TRAFFIC_AWARE");
-        body.put("computeAlternativeRoutes", false);
-        body.put("units", "METRIC");
         HttpPost request = new HttpPost(ENDPOINT);
         request.setConfig(TIMEOUTS);
         request.setHeader("X-Goog-Api-Key", keys.get(0).getDictValue());
@@ -76,6 +77,34 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         }
     }
 
+    /** 两点和多点共用的地图请求体,保留重复坐标对应的独立站点。 */
+    JSONObject requestBody(List<GeoPoint> points, Integer vehicleType) {
+        FlashDeliveryRouteService.validatePoints(points);
+        JSONObject body = new JSONObject();
+        GeoPoint origin = points.get(0);
+        GeoPoint destination = points.get(points.size() - 1);
+        body.put("origin", waypoint(origin.latitude(), origin.longitude()));
+        body.put("destination", waypoint(destination.latitude(), destination.longitude()));
+        if (points.size() > 2) {
+            JSONArray intermediates = new JSONArray();
+            for (GeoPoint point : points.subList(1, points.size() - 1)) {
+                intermediates.add(waypoint(point.latitude(), point.longitude()));
+            }
+            body.put("intermediates", intermediates);
+        }
+        body.put("optimizeWaypointOrder", false);
+        body.put("travelMode", travelModeFor(vehicleType));
+        body.put("routingPreference", "TRAFFIC_AWARE");
+        body.put("computeAlternativeRoutes", false);
+        body.put("units", "METRIC");
+        return body;
+    }
+
+    /** 保留测试和旧调用的默认机车车型。 */
+    JSONObject requestBody(List<GeoPoint> points) {
+        return requestBody(points, FlashDeliveryVehicleType.MOTORCYCLE);
+    }
+
     private JSONObject waypoint(BigDecimal latitude, BigDecimal longitude) {
         JSONObject latLng = new JSONObject();
         latLng.put("latitude", latitude);

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java

@@ -1,12 +1,22 @@
 package com.ruoyi.app.flashdelivery.route;
 
 import java.math.BigDecimal;
+import java.util.List;
 
 @FunctionalInterface
 /** 外部路线距离提供器,失败时由上层服务负责本地降级。 */
 public interface RouteDistanceProvider {
-    /** 按起终点和用户选择车型计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
+    /** 按起终点和订单车型计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
     RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
                         BigDecimal destinationLatitude, BigDecimal destinationLongitude,
                         Integer vehicleType);
+
+    /** 完整有序路线;多点不允许悄悄省略中间站。 */
+    default RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        FlashDeliveryRouteService.validatePoints(points);
+        if (points.size() != 2) throw new UnsupportedOperationException("multi-stop route unavailable");
+        GeoPoint origin = points.get(0);
+        GeoPoint destination = points.get(1);
+        return route(origin.latitude(), origin.longitude(), destination.latitude(), destination.longitude(), vehicleType);
+    }
 }

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteOutsideRegionException.java

@@ -0,0 +1,8 @@
+package com.ruoyi.app.flashdelivery.route;
+
+/** 自部署地图判定坐标越界(OUTSIDE_REGION);按 spec 边界场景此类失败不降级直线计价。 */
+public class RouteOutsideRegionException extends IllegalStateException {
+    public RouteOutsideRegionException(String message) {
+        super(message);
+    }
+}

+ 85 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/SelfHostedRouteDistanceProvider.java

@@ -0,0 +1,85 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * 自部署地图(Foodie_HCM_Map)路线距离实现:单次请求 {@code GET /route/multi?coords=},
+ * 地图按原始顺序途经全部站点返回整线道路距离(每站仅吸附一次)。车型不参与请求——
+ * 地图仅 car 档,距离同值,时长仅作展示参考(032 FR-008 由平台系数折算)。
+ * 失败语义:路线失败/超时/吸附失败抛错交上层直线兜底;坐标越界(OUTSIDE_REGION)抛
+ * {@link RouteOutsideRegionException},不降级。
+ */
+@Component
+public class SelfHostedRouteDistanceProvider implements RouteDistanceProvider {
+    private static final RequestConfig TIMEOUTS = RequestConfig.custom()
+            .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
+
+    @Value("${map.self-hosted.base-url:http://127.0.0.1:8080}")
+    private String baseUrl;
+
+    @Override
+    public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                               Integer vehicleType) {
+        return route(List.of(new GeoPoint(originLatitude, originLongitude),
+                new GeoPoint(destinationLatitude, destinationLongitude)), vehicleType);
+    }
+
+    @Override
+    public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        FlashDeliveryRouteService.validatePoints(points);
+        return parseRoute(fetchRoute(points));
+    }
+
+    /** 请求完整多站点路线;非 2xx 按 code 区分:OUTSIDE_REGION 不降级,其余抛错交上层直线兜底。 */
+    JSONObject fetchRoute(List<GeoPoint> points) {
+        StringBuilder coords = new StringBuilder();
+        for (GeoPoint point : points) {
+            if (coords.length() > 0) coords.append(';');
+            // toPlainString 避免科学计数法把 '+' 带进 URL(查询串里 '+' 会被解码成空格)
+            coords.append(point.latitude().toPlainString()).append(',')
+                    .append(point.longitude().toPlainString());
+        }
+        HttpGet request = new HttpGet(baseUrl + "/route/multi?coords=" + coords);
+        request.setConfig(TIMEOUTS);
+        try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
+             CloseableHttpResponse response = client.execute(request)) {
+            int statusCode = response.getStatusLine().getStatusCode();
+            JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
+            if (statusCode < 200 || statusCode >= 300) {
+                // 仅记录状态与错误码,不记录坐标等用户数据。
+                if (json != null && "OUTSIDE_REGION".equals(json.getString("code"))) {
+                    throw new RouteOutsideRegionException("self hosted map outside region");
+                }
+                throw new IllegalStateException("self hosted map http status " + statusCode);
+            }
+            return json;
+        } catch (IllegalStateException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            throw new IllegalStateException("self hosted map request failed", exception);
+        }
+    }
+
+    /** 解析 /route/multi 成功响应,只取计价需要的距离与时长两项。 */
+    RouteDistance parseRoute(JSONObject json) {
+        if (json == null) throw new IllegalStateException("self hosted map empty response");
+        Integer meters = json.getInteger("distance_meters");
+        if (meters == null || meters <= 0) throw new IllegalStateException("self hosted map invalid distance");
+        Integer seconds = json.getInteger("duration_seconds");
+        return new RouteDistance(meters, seconds == null || seconds < 0 ? null : seconds, "ROUTE");
+    }
+}

文件差异内容过多而无法显示
+ 635 - 90
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java


+ 99 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java

@@ -1,14 +1,21 @@
 package com.ruoyi.app.flashdelivery.service;
 
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.push.BusinessPushProperties;
+import com.ruoyi.app.push.NativeRiderBatchPushService;
+import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.RiderPositionMapper;
 import org.slf4j.Logger;
@@ -19,6 +26,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.util.List;
+import java.util.Locale;
 import java.util.TimerTask;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
@@ -39,6 +47,8 @@ public class FlashDeliveryNotificationService {
     private static final String CONTENT_ACCEPTED = "no.message.push.delivery.personnel.receiving.order";
     private static final String CONTENT_PICKED_UP = "no.message.push.delivery.personnel.qspsz.order";
     private static final String CONTENT_DELIVERED = "no.message.push.delivery.personnel.qsysd.order";
+    private static final String CONTENT_PICKUP_STOP = "no.message.push.flash.pickup.stop.completed";
+    private static final String CONTENT_DELIVERY_STOP = "no.message.push.flash.delivery.stop.completed";
     private static final String CONTENT_CANCELLED = "no.message.push.order.cancelled";
     private static final String CONTENT_AVAILABLE = "no.message.push.new.flash.order";
     private static final int RIDER_NOTIFICATION_LIMIT = 20;
@@ -50,17 +60,28 @@ public class FlashDeliveryNotificationService {
     private final InfoUserMapper userMapper;
     private final PushEventService pushEventService;
     private final RiderPositionMapper riderPositionMapper;
+    private final BusinessPushProperties pushProperties;
+    private final NativeRiderBatchPushService nativeRiderBatch;
 
     public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService) {
         this(userMapper, pushEventService, null);
     }
 
-    @Autowired
     public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService,
                                             RiderPositionMapper riderPositionMapper) {
+        this(userMapper, pushEventService, riderPositionMapper, null, null);
+    }
+
+    @Autowired
+    public FlashDeliveryNotificationService(InfoUserMapper userMapper, PushEventService pushEventService,
+                                            RiderPositionMapper riderPositionMapper,
+                                            BusinessPushProperties pushProperties,
+                                            NativeRiderBatchPushService nativeRiderBatch) {
         this.userMapper = userMapper;
         this.pushEventService = pushEventService;
         this.riderPositionMapper = riderPositionMapper;
+        this.pushProperties = pushProperties;
+        this.nativeRiderBatch = nativeRiderBatch;
     }
 
     /** 按订单取寄件用户;订单或用户缺失时静默跳过。 */
@@ -76,10 +97,12 @@ public class FlashDeliveryNotificationService {
     }
 
     /** 向寄件用户推送指定状态文案;消息入库由通道内 PublisherEvent 完成,cid 为空时仅入库。 */
-    void sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey) {
+    void sendToUser(InfoUser recipient, FlashDeliveryOrder order, String status, String contentKey,
+                    UserNotificationScene scene) {
         if (recipient == null || recipient.getUserId() == null) return;
         String orderNo = order.getOrderNo();
-        String body = OrderPushBodyDto.getJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH);
+        // 仅用户履约消息带场景;保持闪送 pushType=3 与原有 type/state。
+        String body = OrderPushBodyDto.getUserJson(orderNo, status, PUSH_PAYLOAD_TYPE_NONE, PUSH_TYPE_FLASH, scene);
         AsyncManager.me().execute(new TimerTask() {
             @Override
             public void run() {
@@ -138,7 +161,15 @@ public class FlashDeliveryNotificationService {
                     order.getPickupLongitude(), order.getPickupLatitude(), vehicleType,
                     order.getDeliveryType(), newTaskDistanceLimit(), RIDER_NOTIFICATION_LIMIT);
             if (riders != null) {
-                riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                if (pushProperties != null && pushProperties.getMode() == BusinessPushProperties.Mode.NATIVE
+                        && nativeRiderBatch != null) {
+                    String orderNo = order.getOrderNo();
+                    String body = OrderPushBodyDto.getJson(orderNo, WAITING_ACCEPTANCE, 0, PUSH_TYPE_FLASH);
+                    nativeRiderBatch.sendNewOrder(riders, TITLE_KEY, CONTENT_AVAILABLE, body, orderNo,
+                            rider -> sendAvailableOrderToRider(rider, order));
+                } else {
+                    riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                }
             }
         });
     }
@@ -147,7 +178,7 @@ public class FlashDeliveryNotificationService {
     public void notifyAccepted(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
             InfoUser sender = senderOf(order);
-            if (sender != null) sendToUser(sender, order, ACCEPTED, CONTENT_ACCEPTED);
+            if (sender != null) sendToUser(sender, order, ACCEPTED, CONTENT_ACCEPTED, UserNotificationScene.RIDER_ACCEPTED);
         });
     }
 
@@ -155,7 +186,7 @@ public class FlashDeliveryNotificationService {
     public void notifyPickedUp(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
             InfoUser sender = senderOf(order);
-            if (sender != null) sendToUser(sender, order, PICKED_UP, CONTENT_PICKED_UP);
+            if (sender != null) sendToUser(sender, order, PICKED_UP, CONTENT_PICKED_UP, UserNotificationScene.ORDER_DELIVERING);
         });
     }
 
@@ -163,7 +194,58 @@ public class FlashDeliveryNotificationService {
     public void notifyDelivered(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
             InfoUser sender = senderOf(order);
-            if (sender != null) sendToUser(sender, order, DELIVERED, CONTENT_DELIVERED);
+            if (sender != null) sendToUser(sender, order, DELIVERED, CONTENT_DELIVERED, UserNotificationScene.ORDER_DELIVERED);
+        });
+    }
+
+    /** 每个取货站首次确认成功后,通知订单发起人本站及取货进度。 */
+    public void notifyPickupStop(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed, int total) {
+        notifyStopProgress(order, stop, completed, total, "FLASH_PICKUP_STOP_COMPLETED", CONTENT_PICKUP_STOP);
+    }
+
+    /** 每个收货站首次确认送达后,通知订单发起人本站及送达进度。 */
+    public void notifyDeliveryStop(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed, int total) {
+        notifyStopProgress(order, stop, completed, total, "FLASH_DELIVERY_STOP_COMPLETED", CONTENT_DELIVERY_STOP);
+    }
+
+    private void notifyStopProgress(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed,
+                                    int total, String eventType, String contentKey) {
+        if (order == null || stop == null || order.getUserId() == null || stop.getStopOrder() == null) return;
+        Long senderId = order.getUserId();
+        String orderNo = order.getOrderNo();
+        int routeStopNumber = stop.getStopOrder() + 1;
+        // 在本次成功动作处固定状态和进度;不序列化联系人、地址或收货码。
+        JSONObject payload = new JSONObject();
+        payload.put("ddId", orderNo);
+        payload.put("state", order.getStatus());
+        payload.put("type", PUSH_PAYLOAD_TYPE_NONE);
+        payload.put("pushType", PUSH_TYPE_FLASH);
+        payload.put("eventType", eventType);
+        payload.put("stopId", stop.getId());
+        payload.put("stopType", stop.getStopType());
+        payload.put("stopOrder", stop.getStopOrder());
+        payload.put("completedStopCount", completed);
+        payload.put("totalStopCount", total);
+        String body = payload.toJSONString();
+        runAfterCommit(() -> {
+            InfoUser sender = userMapper.selectById(senderId);
+            if (sender == null) return;
+            String cid = sender.getCid();
+            AsyncManager.me().execute(new TimerTask() {
+                @Override
+                public void run() {
+                    try {
+                        Locale locale = LocaleUtils.getUserLocale(senderId);
+                        String title = MessageUtils.message(TITLE_KEY, locale);
+                        String content = MessageUtils.message(contentKey, locale, routeStopNumber, completed, total)
+                                + ",NO:" + orderNo;
+                        PayPush.userPush(new PayPush(), pushEventService, senderId, cid, title, content, body, "");
+                    } catch (Exception exception) {
+                        log.error("闪送逐站用户推送失败,userId:{}, orderNo:{}, eventType:{}",
+                                senderId, orderNo, eventType, exception);
+                    }
+                }
+            });
         });
     }
 
@@ -177,14 +259,22 @@ public class FlashDeliveryNotificationService {
 
     /** 仅事务提交后执行推送准备;无事务同步时(单测)直接执行。 */
     static void runAfterCommit(Runnable action) {
+        // 查询收件人或投递异步任务失败同样属于通知失败,不改变已成功的履约结果。
+        Runnable safeAction = () -> {
+            try {
+                action.run();
+            } catch (Exception exception) {
+                log.error("闪送通知准备失败", exception);
+            }
+        };
         if (!TransactionSynchronizationManager.isSynchronizationActive()) {
-            action.run();
+            safeAction.run();
             return;
         }
         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
             public void afterCommit() {
-                action.run();
+                safeAction.run();
             }
         });
     }

+ 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);
     }
 }

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryUserCardStops.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopView;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
+import org.springframework.beans.BeanUtils;
+
+import java.util.List;
+
+/** 用户端订单卡片逐站列表:全部站点返回文字地址与备注,寄件收件同口径;联系人、电话、坐标、凭证与收货码不落卡片。 */
+public final class FlashDeliveryUserCardStops {
+    private FlashDeliveryUserCardStops() {}
+
+    public static List<FlashDeliveryStopView> of(List<FlashDeliveryOrderStop> rows) {
+        return rows.stream().map(stop -> {
+            FlashDeliveryStopView view = new FlashDeliveryStopView();
+            BeanUtils.copyProperties(stop, view);
+            view.setDeliveryPinCode(null);
+            view.setName(null); view.setPhone(null);
+            view.setLongitude(null); view.setLatitude(null);
+            return view;
+        }).toList();
+    }
+}

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java

@@ -100,7 +100,7 @@ public class PosFenleiController extends BaseController
         QueryWrapper<PosFenlei> queryWrapper= new QueryWrapper<>();
         queryWrapper.select().orderByDesc("sort");
         queryWrapper.eq("mendid",id);
-        String lang = "0";
+        String lang = "3";
         if(!"".equals(language)){
             lang = language;
         }

+ 771 - 746
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -1,93 +1,98 @@
-package com.ruoyi.app.mendian;
-
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.ruoyi.app.mendian.dto.FoodSearchOutput;
-import com.ruoyi.app.order.OrderInvoiceService;
+package com.ruoyi.app.mendian;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.mendian.dto.FoodSearchOutput;
+import com.ruoyi.app.mendian.dto.FoodAuditRequest;
+import com.ruoyi.app.order.OrderInvoiceService;
 import com.ruoyi.app.utils.ImageCompressUtils;
 import com.ruoyi.common.annotation.Anonymous;
-import com.ruoyi.common.annotation.Log;
-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.enums.BusinessType;
-import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.common.utils.StringUtils;
-import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.system.domain.*;
-import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.common.annotation.Log;
+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.enums.BusinessType;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.dto.menu.StoreMenuCopyExecuteRequest;
+import com.ruoyi.system.dto.menu.StoreMenuCopyPreviewRequest;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.*;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-import jakarta.servlet.http.HttpServletResponse;
-
-import java.util.*;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Collectors;
-
-/**
- * foodController
- *
- * @author ruoyi
- * @date 2023-05-21
- */
-@RestController
-@RequestMapping("/chanting/food")
-public class PosFoodController extends BaseController {
-    @Autowired  //商品
-    private IPosFoodService posFoodService;
-
-    @Autowired  //门店
-    private IPosStoreService posStoreService;
-
-    @Autowired //商品分类
-    private IPosFenleiService posFenleiService;
-
-    @Autowired //用户
-    private IInfoUserService infoUserService;
-    @Autowired //店铺Mapper
-    private PosStoreMapper posStoreMapper;
-    @Autowired
-    private IPosReviewService posReviewService;
-    @Autowired
-    private IOperatingHoursService operatingHoursService;
-    @Autowired
-    private IPosOrderService posOrderService;
-
-    @Autowired
-    private PosStoreEnrichService posStoreEnrichService;
-
-    @Autowired  //规格组
-    private IFoodSpecsService foodSpecsService;
-    @Autowired  //规格值
-    private IFoodSpecsValueService foodSpecsValueService;
-    @Autowired  //商品-规格关联
-    private IFoodSpecRelationService foodSpecRelationService;
-
-    @Autowired //订单电子发票(门店是否能开票)
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/**
+ * foodController
+ *
+ * @author ruoyi
+ * @date 2023-05-21
+ */
+@RestController
+@RequestMapping("/chanting/food")
+public class PosFoodController extends BaseController {
+    @Autowired  //商品
+    private IPosFoodService posFoodService;
+
+    @Autowired  //门店
+    private IPosStoreService posStoreService;
+
+    @Autowired //商品分类
+    private IPosFenleiService posFenleiService;
+
+    @Autowired //用户
+    private IInfoUserService infoUserService;
+    @Autowired //店铺Mapper
+    private PosStoreMapper posStoreMapper;
+    @Autowired
+    private IPosReviewService posReviewService;
+    @Autowired
+    private IOperatingHoursService operatingHoursService;
+    @Autowired
+    private IPosOrderService posOrderService;
+
+    @Autowired
+    private PosStoreEnrichService posStoreEnrichService;
+
+    @Autowired  //规格组
+    private IFoodSpecsService foodSpecsService;
+    @Autowired  //规格值
+    private IFoodSpecsValueService foodSpecsValueService;
+    @Autowired  //商品-规格关联
+    private IFoodSpecRelationService foodSpecRelationService;
+
+    @Autowired //订单电子发票(门店是否能开票)
     private OrderInvoiceService orderInvoiceService;
     @Autowired
     private MerchantStoreAccessService merchantStoreAccessService;
-    //删除商品
-    @Anonymous
+    @Autowired
+    private StoreMenuCopyService storeMenuCopyService;
+    //删除商品
+    @Anonymous
     @Auth
     @GetMapping("/delefood")
     public AjaxResult delefood(@RequestHeader String token, @RequestParam String id) {
         merchantStoreAccessService.requireFoodAccess(
                 AuthContext.requireUserId(), Long.valueOf(id));
         return toAjax(posFoodService.deletePosFoodById(Long.valueOf(id)));
-    }
-
-    //推荐商品
+    }
+
+    //推荐商品
     @Anonymous
     @Auth
     @PutMapping("/changerecommend")
@@ -96,11 +101,29 @@ public class PosFoodController extends BaseController {
                 AuthContext.requireUserId(), posFood.getId());
         posFood.setMdid(existing.getMdid());
         return toAjax(posFoodService.saveOrUpdate(posFood));
-    }
-
-    /**
-     * 添加或修改商品
-     */
+    }
+
+    /**
+     * 平台商品审核使用平台 Authorization 与商品编辑权限,不复用商家 token/门店归属入口。
+     * 只更新审核字段,不走 saveOrUpdate 或规格处理,避免新增商品、改归属或清空规格关联。
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:edit')")
+    @Log(title = "商品审核", businessType = BusinessType.UPDATE)
+    @PutMapping("/audit")
+    public AjaxResult audit(@RequestBody FoodAuditRequest request) {
+        if (request == null || request.getId() == null || request.getId() <= 0
+                || !("0".equals(request.getToExamine()) || "1".equals(request.getToExamine()))) {
+            return error(MessageUtils.message("food.audit.invalid"));
+        }
+        PosFood update = new PosFood();
+        update.setId(request.getId());
+        update.setToExamine(request.getToExamine());
+        return toAjax(posFoodService.updatePosFood(update));
+    }
+
+    /**
+     * 添加或修改商品
+     */
     @Anonymous
     @Auth
     @PostMapping("/setposfood")
@@ -112,683 +135,685 @@ public class PosFoodController extends BaseController {
             PosFood existing = merchantStoreAccessService.requireFoodAccess(userId, posFood.getId());
             posFood.setMdid(existing.getMdid());
         }
-        Boolean org = posFoodService.saveOrUpdate(posFood);
-        if (!org) {
-            return error();
-        }
-        handleFoodSpec(posFood);
-        return success();
-    }
-
-    /**
-     * (h5页面使用)通过分类查询商品列表
-     */
-    @Anonymous
-    @GetMapping("/h5Getidlist")
-    public AjaxResult h5Getidlist(@RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp) {
-        JwtUtil jwtUtil = new JwtUtil();
-//        String ids = jwtUtil.getusid(token);
-//        InfoUser user = infoUserService.getById(ids);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("fl_id", id);
-//        if(!user.getUserType().equals("1")){
-//            queryWrapper.eq("to_examine","1");
-//        }
-        if (stackingUp != null && !"".equals(stackingUp)) {
-            queryWrapper.eq("stacking_up", stackingUp);
-        }
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        queryWrapper.eq("language", lang);
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        JSONArray all = new JSONArray();
-        for (int i = 0; i < list.size(); i++) {
-            JSONObject org = new JSONObject();
-            org.put("id", list.get(i).getId());
-            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
-            org.put("store", posStoreService.getById(list.get(i).getMdid()));
-            org.put("name", list.get(i).getName());
-            org.put("image", list.get(i).getImage());
-            org.put("price", list.get(i).getPrice());
-            org.put("introduce", list.get(i).getIntroduce());
-            org.put("recommend", list.get(i).getRecommend());
-            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
-            org.put("foodSpecs", __fs);
-            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
-            org.put("stackingUp", list.get(i).getStackingUp());
-            org.put("toExamine", list.get(i).getToExamine());
-            all.add(org);
-        }
-        return success(all);
-    }
-
-    /**
-     * 通过分类查询商品列表
-     */
+        Boolean org = posFoodService.saveOrUpdate(posFood);
+        if (!org) {
+            return error();
+        }
+        handleFoodSpec(posFood);
+        return success();
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/menu-copy/preview")
+    public AjaxResult menuCopyPreview(@RequestHeader String token,
+                                      @RequestBody StoreMenuCopyPreviewRequest request) {
+        return success(storeMenuCopyService.preview(AuthContext.requireUserId(), request));
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/menu-copy/execute")
+    public AjaxResult menuCopyExecute(@RequestHeader String token,
+                                      @RequestBody StoreMenuCopyExecuteRequest request) {
+        return success(storeMenuCopyService.execute(AuthContext.requireUserId(), request));
+    }
+
+    /**
+     * (h5页面使用)通过分类查询商品列表
+     */
+    @Anonymous
+    @GetMapping("/h5Getidlist")
+    public AjaxResult h5Getidlist(@RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp) {
+        JwtUtil jwtUtil = new JwtUtil();
+//        String ids = jwtUtil.getusid(token);
+//        InfoUser user = infoUserService.getById(ids);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("fl_id", id);
+//        if(!user.getUserType().equals("1")){
+//            queryWrapper.eq("to_examine","1");
+//        }
+        if (stackingUp != null && !"".equals(stackingUp)) {
+            queryWrapper.eq("stacking_up", stackingUp);
+        }
+        String lang = "3";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        queryWrapper.eq("language", lang);
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        JSONArray all = new JSONArray();
+        for (int i = 0; i < list.size(); i++) {
+            JSONObject org = new JSONObject();
+            org.put("id", list.get(i).getId());
+            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
+            org.put("store", posStoreService.getById(list.get(i).getMdid()));
+            org.put("name", list.get(i).getName());
+            org.put("image", list.get(i).getImage());
+            org.put("price", list.get(i).getPrice());
+            org.put("introduce", list.get(i).getIntroduce());
+            org.put("recommend", list.get(i).getRecommend());
+            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
+            org.put("foodSpecs", __fs);
+            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
+            org.put("stackingUp", list.get(i).getStackingUp());
+            org.put("toExamine", list.get(i).getToExamine());
+            all.add(org);
+        }
+        return success(all);
+    }
+
+    /**
+     * 通过分类查询商品列表
+     */
     @Anonymous
     @Auth
     @GetMapping("/getidlist")
-    public AjaxResult getidlist(@RequestHeader String token, @RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp, @RequestParam(required = false) String name) {
+    public AjaxResult getidlist(@RequestHeader String token, @RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp, @RequestParam(required = false) String name) {
         Long userId = AuthContext.requireUserId();
         InfoUser user = infoUserService.getById(userId);
         PosFenlei category = posFenleiService.getById(id);
         if (category != null && ("1".equals(user.getUserType()) || "5".equals(user.getUserType()))) {
             merchantStoreAccessService.requireStoreAccess(userId, category.getMendid());
         }
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("fl_id", id);
-        //普通用户显示审核通过的
-        if("0".equals(user.getUserType())){
-            queryWrapper.eq("to_examine", "1");
-        }
-        queryWrapper.ne("stacking_up", "1");
-        if (stackingUp != null && !"".equals(stackingUp)) {
-            queryWrapper.eq("stacking_up", stackingUp);
-        }
-        String lang = "3";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        queryWrapper.eq("language", lang);
-        if (name != null && !"".equals(name)) {
-            queryWrapper.apply(" BINARY name LIKE CONCAT('%', {0}, '%')", name);
-        }
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        JSONArray all = new JSONArray();
-        for (int i = 0; i < list.size(); i++) {
-            JSONObject org = new JSONObject();
-            org.put("id", list.get(i).getId());
-            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
-            PosStore store = posStoreService.getById(list.get(i).getMdid());
-            fillStallLocation(store);
-            org.put("store", store);
-            org.put("canInvoice", orderInvoiceService.canInvoice(list.get(i).getMdid()));
-            org.put("name", list.get(i).getName());
-            org.put("image", list.get(i).getImage());
-            org.put("price", list.get(i).getPrice());
-            org.put("introduce", list.get(i).getIntroduce());
-            org.put("recommend", list.get(i).getRecommend());
-            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
-            org.put("foodSpecs", __fs);
-            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
-            org.put("stackingUp", list.get(i).getStackingUp());
-            org.put("toExamine", list.get(i).getToExamine());
-            all.add(org);
-        }
-        return success(all);
-    }
-
-    /**
-     * 查询商品详情
-     */
-    @Anonymous
-    @GetMapping("/getfood")
-    public AjaxResult getfood(@RequestParam Integer id) {
-        JSONObject org = new JSONObject();
-        PosFood posFood = posFoodService.getById(id);
-        List<Long> __foodIds = new ArrayList<>();
-        __foodIds.add(posFood.getId());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        List<FoodSpecs> __fs = __specMap.getOrDefault(posFood.getId(), new ArrayList<>());
-        org.put("id", posFood.getId());
-        org.put("fenlei", posFenleiService.getById(posFood.getFlId()));
-        PosStore store = posStoreService.getById(posFood.getMdid());
-        fillStallLocation(store);
-        org.put("store", store);
-        org.put("canInvoice", orderInvoiceService.canInvoice(posFood.getMdid()));

-        org.put("name", posFood.getName());
-        org.put("image", posFood.getImage());
-        org.put("price", posFood.getPrice());
-        org.put("introduce", posFood.getIntroduce());
-        org.put("recommend", posFood.getRecommend());
-        org.put("stackingUp", posFood.getStackingUp());
-        org.put("foodSpecs", __fs);
-        org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(posFood.getFoodSku()) : buildFoodSkuArray(__fs));
-        return success(org);
-    }
-
-    /**
-     * 搜索
-     */
-    @Anonymous
-    @GetMapping("/searchfor")
-    public AjaxResult searchfor(@RequestParam String keyword, @RequestParam(defaultValue = "") String language) {
-        if (keyword.equals("")) {
-            return error(MessageUtils.message("no.tip.lock.keywork"));
-        }
-        JSONArray all = new JSONArray();
-        JSONObject object = new JSONObject();
-        QueryWrapper<PosFood> query = new QueryWrapper<>();
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        query.eq("language", lang);
-        query.eq("to_examine", "1");
-//        query.like("name",keyword);
-        query.apply("BINARY name LIKE CONCAT('%', {0}, '%')", keyword);
-        List<PosFood> folist = posFoodService.list(query);
-        QueryWrapper<PosStore> wrapper = new QueryWrapper<>();
-        wrapper.eq("off_shelf", "0")
-//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
-                .and(i -> i.apply("BINARY pos_name LIKE CONCAT('%', {0}, '%')", keyword).or().apply("BINARY brief_introduction LIKE CONCAT('%', {0}, '%')", keyword));
-        List<PosStore> stlist = posStoreService.list(wrapper);
-        for (int i = 0; i < folist.size(); i++) {
-            PosStore store = posStoreService.getById(folist.get(i).getMdid());
-            if (store.getOffShelf().equals("0")) {
-                JSONObject org = new JSONObject();
-                org.put("id", folist.get(i).getId());
-                org.put("fenlei", posFenleiService.getById(folist.get(i).getFlId()));
-                org.put("store", posStoreService.getById(folist.get(i).getMdid()));
-                org.put("name", folist.get(i).getName());
-                org.put("image", folist.get(i).getImage());
-                org.put("price", folist.get(i).getPrice());
-                org.put("introduce", folist.get(i).getIntroduce());
-                org.put("recommend", folist.get(i).getRecommend());
-                org.put("stackingUp", folist.get(i).getStackingUp());
-                org.put("foodSku", JSONArray.parseArray(folist.get(i).getFoodSku()));
-                all.add(org);
-            }
-        }
-        object.put("food", all);
-        object.put("store", stlist);
-        return success(object);
-    }
-
-
-    /**
-     * 客户端查询美食
-     *
-     * @param keyword
-     * @param language
-     * @return
-     */
-
-    @Anonymous
-    @GetMapping("/foodSearch")
-    public AjaxResult foodSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
-        if (keyword.isEmpty()) {
-            return error(MessageUtils.message("no.tip.lock.keywork"));
-        }
-
-        // 设置默认分页参数
-        if (pageNum == null || pageNum < 1) {
-            pageNum = 1;
-        }
-        if (pageSize == null || pageSize < 1) {
-            pageSize = 10;
-        }
-
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        keyword = hanlderKeyword(lang, keyword);
-
-        // 创建分页对象
-        Page<PosStore> page = new Page<>(pageNum, pageSize);
-
-        // 1. 先通过联表查询获取分页的店铺列表(包含总数)
-        IPage<PosStore> storePage = posStoreMapper.selectStoresByFoodKeyword(page, keyword, lang);
-        List<PosStore> stores = storePage.getRecords();
-
-        List<FoodSearchOutput> result = new ArrayList<>();
-        Map<Long, List<PosReview>> reviewMap = Collections.emptyMap();
-        Map<Long, Long> orderCountMap = Collections.emptyMap();
-        List<OperatingHours> hourslist=new ArrayList<>();
-        // 2. 一次性查询所有相关商品(避免循环查询数据库)
-        if (!stores.isEmpty()) {
-            // 提取所有店铺ID
-            List<Long> storeIds = stores.stream()
-                    .map(store -> store.getId().longValue())
-                    .collect(Collectors.toList());
-
-            // 一次性查询所有店铺下匹配关键词的商品
-            QueryWrapper<PosFood> foodQuery = new QueryWrapper<>();
-            foodQuery.in("mdid", storeIds)
-                    .eq("language", lang)
-                    .eq("to_examine", "1")
-                    .apply(" CONCAT(name, ' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", keyword);
-
-            List<PosFood> allFoods = posFoodService.list(foodQuery);
-            {
-                List<Long> __foodIds = allFoods.stream().map(PosFood::getId).collect(Collectors.toList());
-                Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-                for (PosFood __f : allFoods) {
-                    __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
-                }
-            }
-
-            // 按店铺ID分组商品
-            Map<Long, List<PosFood>> foodsByStore = allFoods.stream()
-                    .collect(Collectors.groupingBy(PosFood::getMdid));
-            posStoreEnrichService.enrichStoreList(stores);
-            // 3. 为每个店铺设置对应的商品
-            for (PosStore store : stores) {
-                FoodSearchOutput output = new FoodSearchOutput();
-
-                output.setPosStore(store);
-                // 从分组结果中获取该店铺的商品
-                List<PosFood> storeFoods = foodsByStore.getOrDefault(store.getId().longValue(), new ArrayList<>());
-
-                output.setPosFoodList(storeFoods);
-                result.add(output);
-            }
-        }
-
-        // 3. 返回分页结果
-        Map<String, Object> response = new HashMap<>();
-        response.put("list", result);
-        response.put("total", storePage.getTotal());
-        return success(response);
-    }
-
-    //越南语搜索,添加空格,搜索的关键字变成有语意的
-    private String hanlderKeyword(String language, String keyword) {
-        if (language.equals("0")) {
-            if (!keyword.contains(" "))
-                keyword = keyword + " ";
-        }
-        return keyword;
-    }
-
-    /**
-     * 客户端查询门店
-     *
-     * @param keyword
-     * @param language
-     * @return
-     */
-
-    @Anonymous
-    @GetMapping("/storeSearch")
-    public AjaxResult storeSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
-        if (keyword.isEmpty()) {
-            return error(MessageUtils.message("no.tip.lock.keywork"));
-        }
-        QueryWrapper<PosFood> query = new QueryWrapper<>();
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        keyword = hanlderKeyword(lang, keyword);
-        // 设置默认分页参数
-        if (pageNum == null || pageNum < 1) {
-            pageNum = 1;
-        }
-        if (pageSize == null || pageSize < 1) {
-            pageSize = 10;
-        }
-        Page<PosStore> page = new Page<>(pageNum, pageSize);
-        QueryWrapper<PosStore> storeWrapper = new QueryWrapper<>();
-        String finalKeyword = keyword;
-        storeWrapper.eq("off_shelf", "0")
-//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
-                .and(i -> i.apply(" CONCAT(pos_name,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword).or().apply(" CONCAT(brief_introduction,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword));
-        IPage<PosStore> data = posStoreService.page(page, storeWrapper);
-        Map<String, Object> response = new HashMap<>();
-        List<PosStore> stores=data.getRecords();
-        posStoreEnrichService.enrichStoreList(stores);
-        response.put("list", stores);
-        response.put("total", data.getTotal());
-        return success(response);
-    }
-
-
-
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("fl_id", id);
+        //普通用户显示审核通过的
+        if("0".equals(user.getUserType())){
+            queryWrapper.eq("to_examine", "1");
+            queryWrapper.ne("stacking_up", "1");
+        }
+        if (stackingUp != null && !"".equals(stackingUp)) {
+            queryWrapper.eq("stacking_up", stackingUp);
+        }
+        String lang = "3";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        queryWrapper.eq("language", lang);
+        if (name != null && !"".equals(name)) {
+            queryWrapper.apply(" BINARY name LIKE CONCAT('%', {0}, '%')", name);
+        }
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        JSONArray all = new JSONArray();
+        for (int i = 0; i < list.size(); i++) {
+            JSONObject org = new JSONObject();
+            org.put("id", list.get(i).getId());
+            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
+            PosStore store = posStoreService.getById(list.get(i).getMdid());
+            fillStallLocation(store);
+            org.put("store", store);
+            org.put("canInvoice", orderInvoiceService.canInvoice(list.get(i).getMdid()));
+            org.put("name", list.get(i).getName());
+            org.put("image", list.get(i).getImage());
+            org.put("price", list.get(i).getPrice());
+            org.put("introduce", list.get(i).getIntroduce());
+            org.put("recommend", list.get(i).getRecommend());
+            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
+            org.put("foodSpecs", __fs);
+            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
+            org.put("stackingUp", list.get(i).getStackingUp());
+            org.put("toExamine", list.get(i).getToExamine());
+            all.add(org);
+        }
+        return success(all);
+    }
+
+    /**
+     * 查询商品详情
+     */
+    @Anonymous
+    @GetMapping("/getfood")
+    public AjaxResult getfood(@RequestParam Integer id) {
+        JSONObject org = new JSONObject();
+        PosFood posFood = posFoodService.getById(id);
+        List<Long> __foodIds = new ArrayList<>();
+        __foodIds.add(posFood.getId());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        List<FoodSpecs> __fs = __specMap.getOrDefault(posFood.getId(), new ArrayList<>());
+        org.put("id", posFood.getId());
+        org.put("fenlei", posFenleiService.getById(posFood.getFlId()));
+        PosStore store = posStoreService.getById(posFood.getMdid());
+        fillStallLocation(store);
+        org.put("store", store);
+        org.put("canInvoice", orderInvoiceService.canInvoice(posFood.getMdid()));
+
+        org.put("name", posFood.getName());
+        org.put("image", posFood.getImage());
+        org.put("price", posFood.getPrice());
+        org.put("introduce", posFood.getIntroduce());
+        org.put("recommend", posFood.getRecommend());
+        org.put("stackingUp", posFood.getStackingUp());
+        org.put("foodSpecs", __fs);
+        org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(posFood.getFoodSku()) : buildFoodSkuArray(__fs));
+        return success(org);
+    }
+
+    /**
+     * 搜索
+     */
+    @Anonymous
+    @GetMapping("/searchfor")
+    public AjaxResult searchfor(@RequestParam String keyword, @RequestParam(defaultValue = "") String language) {
+        if (keyword.equals("")) {
+            return error(MessageUtils.message("no.tip.lock.keywork"));
+        }
+        JSONArray all = new JSONArray();
+        JSONObject object = new JSONObject();
+        QueryWrapper<PosFood> query = new QueryWrapper<>();
+        String lang = "3";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        query.eq("language", lang);
+        query.eq("to_examine", "1");
+//        query.like("name",keyword);
+        query.apply("BINARY name LIKE CONCAT('%', {0}, '%')", keyword);
+        List<PosFood> folist = posFoodService.list(query);
+        QueryWrapper<PosStore> wrapper = new QueryWrapper<>();
+        wrapper.eq("off_shelf", "0")
+//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
+                .and(i -> i.apply("BINARY pos_name LIKE CONCAT('%', {0}, '%')", keyword).or().apply("BINARY brief_introduction LIKE CONCAT('%', {0}, '%')", keyword));
+        List<PosStore> stlist = posStoreService.list(wrapper);
+        for (int i = 0; i < folist.size(); i++) {
+            PosStore store = posStoreService.getById(folist.get(i).getMdid());
+            if (store.getOffShelf().equals("0")) {
+                JSONObject org = new JSONObject();
+                org.put("id", folist.get(i).getId());
+                org.put("fenlei", posFenleiService.getById(folist.get(i).getFlId()));
+                org.put("store", posStoreService.getById(folist.get(i).getMdid()));
+                org.put("name", folist.get(i).getName());
+                org.put("image", folist.get(i).getImage());
+                org.put("price", folist.get(i).getPrice());
+                org.put("introduce", folist.get(i).getIntroduce());
+                org.put("recommend", folist.get(i).getRecommend());
+                org.put("stackingUp", folist.get(i).getStackingUp());
+                org.put("foodSku", JSONArray.parseArray(folist.get(i).getFoodSku()));
+                all.add(org);
+            }
+        }
+        object.put("food", all);
+        object.put("store", stlist);
+        return success(object);
+    }
+
+
+    /**
+     * 客户端查询美食
+     *
+     * @param keyword
+     * @param language
+     * @return
+     */
+
+    @Anonymous
+    @GetMapping("/foodSearch")
+    public AjaxResult foodSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
+        if (keyword.isEmpty()) {
+            return error(MessageUtils.message("no.tip.lock.keywork"));
+        }
+
+        // 设置默认分页参数
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+
+        String lang = "3";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+
+        // 创建分页对象
+        Page<PosStore> page = new Page<>(pageNum, pageSize);
+
+        // 1. 先通过联表查询获取分页的店铺列表(包含总数)
+        IPage<PosStore> storePage = posStoreMapper.selectStoresByFoodKeyword(page, keyword, lang);
+        List<PosStore> stores = storePage.getRecords();
+
+        List<FoodSearchOutput> result = new ArrayList<>();
+        Map<Long, List<PosReview>> reviewMap = Collections.emptyMap();
+        Map<Long, Long> orderCountMap = Collections.emptyMap();
+        List<OperatingHours> hourslist=new ArrayList<>();
+        // 2. 一次性查询所有相关商品(避免循环查询数据库)
+        if (!stores.isEmpty()) {
+            // 提取所有店铺ID
+            List<Long> storeIds = stores.stream()
+                    .map(store -> store.getId().longValue())
+                    .collect(Collectors.toList());
+
+            // 一次性查询所有店铺下匹配关键词的商品
+            QueryWrapper<PosFood> foodQuery = new QueryWrapper<>();
+            foodQuery.in("mdid", storeIds)
+                    .eq("language", lang)
+                    .eq("to_examine", "1")
+                    .apply(" CONCAT(name, ' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", keyword);
+
+            List<PosFood> allFoods = posFoodService.list(foodQuery);
+            {
+                List<Long> __foodIds = allFoods.stream().map(PosFood::getId).collect(Collectors.toList());
+                Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+                for (PosFood __f : allFoods) {
+                    __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
+                }
+            }
+
+            // 按店铺ID分组商品
+            Map<Long, List<PosFood>> foodsByStore = allFoods.stream()
+                    .collect(Collectors.groupingBy(PosFood::getMdid));
+            posStoreEnrichService.enrichStoreList(stores);
+            // 3. 为每个店铺设置对应的商品
+            for (PosStore store : stores) {
+                FoodSearchOutput output = new FoodSearchOutput();
+
+                output.setPosStore(store);
+                // 从分组结果中获取该店铺的商品
+                List<PosFood> storeFoods = foodsByStore.getOrDefault(store.getId().longValue(), new ArrayList<>());
+
+                output.setPosFoodList(storeFoods);
+                result.add(output);
+            }
+        }
+
+        // 3. 返回分页结果
+        Map<String, Object> response = new HashMap<>();
+        response.put("list", result);
+        response.put("total", storePage.getTotal());
+        return success(response);
+    }
+
+    /**
+     * 客户端查询门店
+     *
+     * @param keyword
+     * @param language
+     * @return
+     */
+
+    @Anonymous
+    @GetMapping("/storeSearch")
+    public AjaxResult storeSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
+        if (keyword.isEmpty()) {
+            return error(MessageUtils.message("no.tip.lock.keywork"));
+        }
+        QueryWrapper<PosFood> query = new QueryWrapper<>();
+        // 设置默认分页参数
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+        Page<PosStore> page = new Page<>(pageNum, pageSize);
+        QueryWrapper<PosStore> storeWrapper = new QueryWrapper<>();
+        String finalKeyword = keyword;
+        storeWrapper.eq("off_shelf", "0")
+//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
+                .and(i -> i.apply(" CONCAT(pos_name,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword).or().apply(" CONCAT(brief_introduction,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword));
+        IPage<PosStore> data = posStoreService.page(page, storeWrapper);
+        Map<String, Object> response = new HashMap<>();
+        List<PosStore> stores=data.getRecords();
+        posStoreEnrichService.enrichStoreList(stores);
+        response.put("list", stores);
+        response.put("total", data.getTotal());
+        return success(response);
+    }
+
+
+
     @Anonymous
     @Auth
     @GetMapping("/getFoodPageList")
-    public AjaxResult getFoodPageList(@RequestHeader String token,
-                                      @RequestParam Integer page,
-                                      @RequestParam Integer size,
-                                      @RequestParam Long flId,
-                                      @RequestParam Long mdId,
-                                      @RequestParam(defaultValue = "") String language,
-                                      @RequestParam(defaultValue = "") String name) {
+    public AjaxResult getFoodPageList(@RequestHeader String token,
+                                      @RequestParam Integer page,
+                                      @RequestParam Integer size,
+                                      @RequestParam Long flId,
+                                      @RequestParam Long mdId,
+                                      @RequestParam(defaultValue = "") String language,
+                                      @RequestParam(defaultValue = "") String name) {
         Long id = AuthContext.requireUserId();
         merchantStoreAccessService.requireStoreAccess(id, mdId);
-        IPage<PosFood> stlist = new Page<>(page, size);
-        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
-        queryWrapper.eq(PosFood::getFlId, flId);
-        queryWrapper.eq(PosFood::getMdid, mdId);
-
-        // language 不为 null 且不为空字符串时才加条件
-        if (language != null && !"".equals(language)) {
-            queryWrapper.eq(PosFood::getLanguage, language);
-        }
-
-        // name 不为 null 且不为空字符串时模糊查询
-        if (name != null && !"".equals(name)) {
-            queryWrapper.like(PosFood::getName, name);
-        }
-
-        IPage<PosFood> list = posFoodService.page(stlist, queryWrapper);
-        return success(list);
-    }
-
-    /**
-     * C端扫码:获取摊位商品列表(摊位码扫码入口)
-     */
-    @Anonymous
-    @GetMapping("/stallFoodList")
-    public AjaxResult stallFoodList(@RequestParam Long storeId,
-                                    @RequestParam(required = false) Long flId,
-                                    @RequestParam Integer page,
-                                    @RequestParam Integer size,
-                                    @RequestParam(required = false) Long tableId) {
-        IPage<PosFood> foodPage = new Page<>(page, size);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("mdid", storeId);
-        queryWrapper.eq("to_examine", "1");
-        queryWrapper.eq("stacking_up", "1");
-        if (flId != null) {
-            queryWrapper.eq("fl_id", flId);
-        }
-        IPage<PosFood> result = posFoodService.page(foodPage, queryWrapper);
-        List<Long> __foodIds = result.getRecords().stream().map(PosFood::getId).collect(Collectors.toList());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        for (PosFood __f : result.getRecords()) {
-            __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
-        }
-        return success(result);
-    }
-
-    /**
-     * 查询商品列表
-     */
-    @Anonymous
-    @GetMapping("/getfoodlist")
-    public AjaxResult getfoodlist(@RequestParam Integer page,
-                                  @RequestParam Integer size,
-                                  @RequestParam(defaultValue = "") String language,
-                                  @RequestParam(defaultValue = "") String keyword) {
-        IPage<PosFood> food = new Page<>(page, size);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        queryWrapper.eq("language", lang);
-        queryWrapper.eq("to_examine", "1");
-        if (!"".equals(keyword)) {
-            queryWrapper.like("name", keyword);
-        }
-        IPage<PosFood> list = posFoodService.page(food, queryWrapper);
-        List<PosFood> foodlist = list.getRecords();
-        JSONArray all = new JSONArray();
-        for (int i = 0; i < foodlist.size(); i++) {
-            PosStore store = posStoreService.getById(foodlist.get(i).getMdid());
-            if (store.getOffShelf().equals("0")) {
-                JSONObject org = new JSONObject();
-                org.put("id", foodlist.get(i).getId());
-                org.put("fenlei", posFenleiService.getById(foodlist.get(i).getFlId()));
-                org.put("store", posStoreService.getById(foodlist.get(i).getMdid()));
-                org.put("name", foodlist.get(i).getName());
-                org.put("image", foodlist.get(i).getImage());
-                org.put("price", foodlist.get(i).getPrice());
-                org.put("introduce", foodlist.get(i).getIntroduce());
-                org.put("recommend", foodlist.get(i).getRecommend());
-                org.put("stackingUp", foodlist.get(i).getStackingUp());
-                org.put("foodSku", JSONArray.parseArray(foodlist.get(i).getFoodSku()));
-                all.add(org);
-            }
-        }
-        return success(all);
-    }
-
-    /**
-     * 查询food列表
-     */
-
-    @PreAuthorize("@ss.hasPermi('chanting:food:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(PosFood posFood) {
-        startPage();
-        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
-        return getDataTable(list);
-    }
-
-    /**
-     * 导出food列表
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:export')")
-    @Log(title = "food", businessType = BusinessType.EXPORT)
-    @PostMapping("/export")
-    public void export(HttpServletResponse response, PosFood posFood) {
-        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
-        ExcelUtil<PosFood> util = new ExcelUtil<PosFood>(PosFood.class);
-        util.exportExcel(response, list, MessageUtils.message("no.export.excel.food"));
-    }
-
-    /**
-     * 获取food详细信息
-     */
-    @Anonymous
-    @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id) {
-        return success(posFoodService.selectPosFoodById(id));
-    }
-
-    /**
-     * 新增food
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:add')")
-    @Log(title = "food", businessType = BusinessType.INSERT)
-    @PostMapping
-    public AjaxResult add(@RequestBody PosFood posFood) {
-        int rows = posFoodService.insertPosFood(posFood);
-        if (rows > 0) {
-            handleFoodSpec(posFood);
-        }
-        return toAjax(rows);
-    }
-
-    /**
-     * 修改food
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:edit')")
-    @Log(title = "food", businessType = BusinessType.UPDATE)
-    @PutMapping
-    public AjaxResult edit(@RequestBody PosFood posFood) {
-        int rows = posFoodService.updatePosFood(posFood);
-        if (rows > 0) {
-            handleFoodSpec(posFood);
-        }
-        return toAjax(rows);
-    }
-
-    /**
-     * 删除food
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:remove')")
-    @Log(title = "food", businessType = BusinessType.DELETE)
-    @DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids) {
-        return toAjax(posFoodService.deletePosFoodByIds(ids));
-    }
-
-    /**
-     * 压缩图片店铺的商品图片
-     */
-    @Anonymous
-    @GetMapping("/compressFoodImage")
-    public AjaxResult compressFoodImage(@RequestParam Long mdId, @RequestParam String idsStr) {
-        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
-        queryWrapper.eq(PosFood::getMdid, mdId);
-        List<Long> ids= new ArrayList<>();
-        if(!StringUtils.isEmpty(idsStr)){
-            ids = Arrays.stream(idsStr.split(","))
-                    .map(Long::valueOf)
-                    .collect(Collectors.toList());
-        }
-        queryWrapper.in(!ids.isEmpty(), PosFood::getId, ids);
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        AtomicInteger count = new AtomicInteger();
-        list.forEach(food -> {
-            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
-            if (!compressImage.equals(food.getImage())) {
-                food.setImage(compressImage);
-                PosFood posFood = new PosFood();
-                posFood.setId(food.getId());
-                posFood.setImage(compressImage);
-                posFoodService.saveOrUpdate(posFood);
-                count.getAndIncrement();
-                System.out.println("food成功压缩的图片数量:"+count.get());
-                logger.info("food成功压缩的图片数量:"+count.get());
-            }
-        });
-        return success("成功压缩的 :"+count.get());
-    }
-
-    /**
-     * 压缩所有的商品图片
-     */
-    @Anonymous
-    @GetMapping("/compressFoodImageAll")
-    public AjaxResult compressFoodImage() {
-        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        AtomicInteger count = new AtomicInteger();
-        list.forEach(food -> {
-            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
-            if (!compressImage.equals(food.getImage())) {
-                food.setImage(compressImage);
-                PosFood posFood = new PosFood();
-                posFood.setId(food.getId());
-                posFood.setImage(compressImage);
-                posFoodService.saveOrUpdate(posFood);
-                count.getAndIncrement();
-                System.out.println("food成功压缩的图片数量:"+count.get());
-                logger.info("food成功压缩的图片数量:"+count.get());
-            }
-        });
-        return success("成功压缩的图片数量:"+count.get());
-    }
-
-    /**
-     * 当门店是摊位时,用夜市的经纬度覆盖摊位的经纬度
-     */
-    /**
-     * 保存商品规格:sku JSON 写 food_sku(落库) -> 删除旧关联 -> 按 foodSpecs 建立新关联
-     */
-    private void handleFoodSpec(PosFood posFood) {
-        if (posFood == null || posFood.getId() == null) {
-            return;
-        }
-        JSONArray sku = posFood.getSku();
-        if (sku != null) {
-            PosFood upd = new PosFood();
-            upd.setId(posFood.getId());
-            upd.setFoodSku(sku.toString());
-            posFoodService.updateById(upd);
-            posFood.setFoodSku(sku.toString());
-        }
-        foodSpecRelationService.remove(new LambdaQueryWrapper<FoodSpecRelation>()
-                .eq(FoodSpecRelation::getFoodId, posFood.getId()));
-        List<FoodSpecs> foodSpecs = posFood.getFoodSpecs();
-        if (foodSpecs != null && !foodSpecs.isEmpty()) {
-            List<FoodSpecRelation> rels = foodSpecs.stream()
-                    .filter(sp -> sp.getId() != null && sp.getId() > 0)
-                    .map(sp -> {
-                        FoodSpecRelation r = new FoodSpecRelation();
-                        r.setFoodId(posFood.getId());
-                        r.setSpecsId(sp.getId());
-                        return r;
-                    }).collect(Collectors.toList());
-            if (!rels.isEmpty()) {
-                foodSpecRelationService.saveBatch(rels);
-            }
-        }
-    }
-
-    /**
-     * 批量查询商品规格(避免 N+1):关联 -> 启用规格组 -> 启用规格值 -> 内存分组
-     * @return foodId -> 规格组列表(含启用规格值)
-     */
-    private Map<Long, List<FoodSpecs>> loadFoodSpecsMap(List<Long> foodIds) {
-        Map<Long, List<FoodSpecs>> result = new HashMap<>();
-        if (foodIds == null || foodIds.isEmpty()) {
-            return result;
-        }
-        List<FoodSpecRelation> rels = foodSpecRelationService.list(new LambdaQueryWrapper<FoodSpecRelation>()
-                .in(FoodSpecRelation::getFoodId, foodIds));
-        if (rels.isEmpty()) {
-            return result;
-        }
-        Map<Long, List<Long>> foodToSpecIds = rels.stream()
-                .collect(Collectors.groupingBy(FoodSpecRelation::getFoodId,
-                        Collectors.mapping(FoodSpecRelation::getSpecsId, Collectors.toList())));
-        List<Long> specsIds = rels.stream().map(FoodSpecRelation::getSpecsId).distinct().collect(Collectors.toList());
-        List<FoodSpecs> specsList = foodSpecsService.list(new LambdaQueryWrapper<FoodSpecs>()
-                .in(FoodSpecs::getId, specsIds)
-                .eq(FoodSpecs::getIsOpen, true)
-                .eq(FoodSpecs::getIsDelete, false)
-                .orderByAsc(FoodSpecs::getSort));
-        if (specsList.isEmpty()) {
-            return result;
-        }
-        List<Long> enabledSpecIds = specsList.stream().map(FoodSpecs::getId).collect(Collectors.toList());
-        List<FoodSpecsValue> values = foodSpecsValueService.list(new LambdaQueryWrapper<FoodSpecsValue>()
-                .in(FoodSpecsValue::getParentId, enabledSpecIds)
-                .eq(FoodSpecsValue::getIsOpen, true)
-                .orderByAsc(FoodSpecsValue::getId));
-        Map<Long, List<FoodSpecsValue>> valueMap = values.stream()
-                .collect(Collectors.groupingBy(FoodSpecsValue::getParentId));
-        Map<Long, FoodSpecs> specsMap = specsList.stream().collect(Collectors.toMap(FoodSpecs::getId, sp -> sp));
-        for (Map.Entry<Long, List<Long>> entry : foodToSpecIds.entrySet()) {
-            List<FoodSpecs> fsList = new ArrayList<>();
-            for (Long sid : entry.getValue()) {
-                FoodSpecs src = specsMap.get(sid);
-                if (src != null) {
-                    FoodSpecs copy = new FoodSpecs();
-                    copy.setId(src.getId());
-                    copy.setTitle(src.getTitle());
-                    copy.setType(src.getType());
-                    copy.setState(src.getState());
-                    copy.setLanguage(src.getLanguage());
-                    copy.setRemark(src.getRemark());
-                    copy.setMdId(src.getMdId());
-                    copy.setSort(src.getSort());
-                    copy.setIsOpen(src.getIsOpen());
-                    copy.setIsDelete(src.getIsDelete());
-                    copy.setObjects(valueMap.getOrDefault(src.getId(), new ArrayList<>()));
-                    fsList.add(copy);
-                }
-            }
-            result.put(entry.getKey(), fsList);
-        }
-        return result;
-    }
-
-    /**
-     * 由结构化规格构建兼容的 food_sku JSON 数组(明细字段名 foodSpecsItems)
-     */
-    private JSONArray buildFoodSkuArray(List<FoodSpecs> fsList) {
-        JSONArray skuArr = new JSONArray();
-        if (fsList == null || fsList.isEmpty()) {
-            return skuArr;
-        }
-        for (FoodSpecs s : fsList) {
-            skuArr.add(JSONObject.toJSON(s));
-        }
-        return skuArr;
-    }
-
-    private void fillStallLocation(PosStore store) {
-        if (store != null && store.getIsStall() != null && store.getIsStall() == 1
-                && store.getNightMarketId() != null) {
-            InfoUser nightMarket = infoUserService.getById(store.getNightMarketId());
-            if (nightMarket != null) {
-                store.setLongitude(nightMarket.getLongitude());
-                store.setLatitude(nightMarket.getLatitude());
-            }
-        }
-    }
-}
+        IPage<PosFood> stlist = new Page<>(page, size);
+        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(PosFood::getFlId, flId);
+        queryWrapper.eq(PosFood::getMdid, mdId);
+
+        // language 不为 null 且不为空字符串时才加条件
+        if (language != null && !"".equals(language)) {
+            queryWrapper.eq(PosFood::getLanguage, language);
+        }
+
+        // name 不为 null 且不为空字符串时模糊查询
+        if (name != null && !"".equals(name)) {
+            queryWrapper.like(PosFood::getName, name);
+        }
+
+        IPage<PosFood> list = posFoodService.page(stlist, queryWrapper);
+        return success(list);
+    }
+
+    /**
+     * C端扫码:获取摊位商品列表(摊位码扫码入口)
+     */
+    @Anonymous
+    @GetMapping("/stallFoodList")
+    public AjaxResult stallFoodList(@RequestParam Long storeId,
+                                    @RequestParam(required = false) Long flId,
+                                    @RequestParam Integer page,
+                                    @RequestParam Integer size,
+                                    @RequestParam(required = false) Long tableId) {
+        IPage<PosFood> foodPage = new Page<>(page, size);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("mdid", storeId);
+        queryWrapper.eq("to_examine", "1");
+        queryWrapper.eq("stacking_up", "1");
+        if (flId != null) {
+            queryWrapper.eq("fl_id", flId);
+        }
+        IPage<PosFood> result = posFoodService.page(foodPage, queryWrapper);
+        List<Long> __foodIds = result.getRecords().stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        for (PosFood __f : result.getRecords()) {
+            __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
+        }
+        return success(result);
+    }
+
+    /**
+     * 查询商品列表
+     */
+    @Anonymous
+    @GetMapping("/getfoodlist")
+    public AjaxResult getfoodlist(@RequestParam Integer page,
+                                  @RequestParam Integer size,
+                                  @RequestParam(defaultValue = "") String language,
+                                  @RequestParam(defaultValue = "") String keyword) {
+        IPage<PosFood> food = new Page<>(page, size);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        String lang = "3";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        queryWrapper.eq("language", lang);
+        queryWrapper.eq("to_examine", "1");
+        if (!"".equals(keyword)) {
+            queryWrapper.like("name", keyword);
+        }
+        IPage<PosFood> list = posFoodService.page(food, queryWrapper);
+        List<PosFood> foodlist = list.getRecords();
+        JSONArray all = new JSONArray();
+        for (int i = 0; i < foodlist.size(); i++) {
+            PosStore store = posStoreService.getById(foodlist.get(i).getMdid());
+            if (store.getOffShelf().equals("0")) {
+                JSONObject org = new JSONObject();
+                org.put("id", foodlist.get(i).getId());
+                org.put("fenlei", posFenleiService.getById(foodlist.get(i).getFlId()));
+                org.put("store", posStoreService.getById(foodlist.get(i).getMdid()));
+                org.put("name", foodlist.get(i).getName());
+                org.put("image", foodlist.get(i).getImage());
+                org.put("price", foodlist.get(i).getPrice());
+                org.put("introduce", foodlist.get(i).getIntroduce());
+                org.put("recommend", foodlist.get(i).getRecommend());
+                org.put("stackingUp", foodlist.get(i).getStackingUp());
+                org.put("foodSku", JSONArray.parseArray(foodlist.get(i).getFoodSku()));
+                all.add(org);
+            }
+        }
+        return success(all);
+    }
+
+    /**
+     * 查询food列表
+     */
+
+    @PreAuthorize("@ss.hasPermi('chanting:food:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(PosFood posFood) {
+        startPage();
+        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出food列表
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:export')")
+    @Log(title = "food", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, PosFood posFood) {
+        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
+        ExcelUtil<PosFood> util = new ExcelUtil<PosFood>(PosFood.class);
+        util.exportExcel(response, list, MessageUtils.message("no.export.excel.food"));
+    }
+
+    /**
+     * 获取food详细信息
+     */
+    @Anonymous
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return success(posFoodService.selectPosFoodById(id));
+    }
+
+    /**
+     * 新增food
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:add')")
+    @Log(title = "food", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody PosFood posFood) {
+        int rows = posFoodService.insertPosFood(posFood);
+        if (rows > 0) {
+            handleFoodSpec(posFood);
+        }
+        return toAjax(rows);
+    }
+
+    /**
+     * 修改food
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:edit')")
+    @Log(title = "food", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody PosFood posFood) {
+        int rows = posFoodService.updatePosFood(posFood);
+        if (rows > 0) {
+            handleFoodSpec(posFood);
+        }
+        return toAjax(rows);
+    }
+
+    /**
+     * 删除food
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:remove')")
+    @Log(title = "food", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(posFoodService.deletePosFoodByIds(ids));
+    }
+
+    /**
+     * 压缩图片店铺的商品图片
+     */
+    @Anonymous
+    @GetMapping("/compressFoodImage")
+    public AjaxResult compressFoodImage(@RequestParam Long mdId, @RequestParam String idsStr) {
+        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(PosFood::getMdid, mdId);
+        List<Long> ids= new ArrayList<>();
+        if(!StringUtils.isEmpty(idsStr)){
+            ids = Arrays.stream(idsStr.split(","))
+                    .map(Long::valueOf)
+                    .collect(Collectors.toList());
+        }
+        queryWrapper.in(!ids.isEmpty(), PosFood::getId, ids);
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        AtomicInteger count = new AtomicInteger();
+        list.forEach(food -> {
+            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
+            if (!compressImage.equals(food.getImage())) {
+                food.setImage(compressImage);
+                PosFood posFood = new PosFood();
+                posFood.setId(food.getId());
+                posFood.setImage(compressImage);
+                posFoodService.saveOrUpdate(posFood);
+                count.getAndIncrement();
+                System.out.println("food成功压缩的图片数量:"+count.get());
+                logger.info("food成功压缩的图片数量:"+count.get());
+            }
+        });
+        return success("成功压缩的 :"+count.get());
+    }
+
+    /**
+     * 压缩所有的商品图片
+     */
+    @Anonymous
+    @GetMapping("/compressFoodImageAll")
+    public AjaxResult compressFoodImage() {
+        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        AtomicInteger count = new AtomicInteger();
+        list.forEach(food -> {
+            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
+            if (!compressImage.equals(food.getImage())) {
+                food.setImage(compressImage);
+                PosFood posFood = new PosFood();
+                posFood.setId(food.getId());
+                posFood.setImage(compressImage);
+                posFoodService.saveOrUpdate(posFood);
+                count.getAndIncrement();
+                System.out.println("food成功压缩的图片数量:"+count.get());
+                logger.info("food成功压缩的图片数量:"+count.get());
+            }
+        });
+        return success("成功压缩的图片数量:"+count.get());
+    }
+
+    /**
+     * 当门店是摊位时,用夜市的经纬度覆盖摊位的经纬度
+     */
+    /**
+     * 保存商品规格:sku JSON 写 food_sku(落库) -> 删除旧关联 -> 按 foodSpecs 建立新关联
+     */
+    private void handleFoodSpec(PosFood posFood) {
+        if (posFood == null || posFood.getId() == null) {
+            return;
+        }
+        JSONArray sku = posFood.getSku();
+        if (sku != null) {
+            PosFood upd = new PosFood();
+            upd.setId(posFood.getId());
+            upd.setFoodSku(sku.toString());
+            posFoodService.updateById(upd);
+            posFood.setFoodSku(sku.toString());
+        }
+        foodSpecRelationService.remove(new LambdaQueryWrapper<FoodSpecRelation>()
+                .eq(FoodSpecRelation::getFoodId, posFood.getId()));
+        List<FoodSpecs> foodSpecs = posFood.getFoodSpecs();
+        if (foodSpecs != null && !foodSpecs.isEmpty()) {
+            List<FoodSpecRelation> rels = foodSpecs.stream()
+                    .filter(sp -> sp.getId() != null && sp.getId() > 0)
+                    .map(sp -> {
+                        FoodSpecRelation r = new FoodSpecRelation();
+                        r.setFoodId(posFood.getId());
+                        r.setSpecsId(sp.getId());
+                        return r;
+                    }).collect(Collectors.toList());
+            if (!rels.isEmpty()) {
+                foodSpecRelationService.saveBatch(rels);
+            }
+        }
+    }
+
+    /**
+     * 批量查询商品规格(避免 N+1):关联 -> 启用规格组 -> 启用规格值 -> 内存分组
+     * @return foodId -> 规格组列表(含启用规格值)
+     */
+    private Map<Long, List<FoodSpecs>> loadFoodSpecsMap(List<Long> foodIds) {
+        Map<Long, List<FoodSpecs>> result = new HashMap<>();
+        if (foodIds == null || foodIds.isEmpty()) {
+            return result;
+        }
+        List<FoodSpecRelation> rels = foodSpecRelationService.list(new LambdaQueryWrapper<FoodSpecRelation>()
+                .in(FoodSpecRelation::getFoodId, foodIds));
+        if (rels.isEmpty()) {
+            return result;
+        }
+        Map<Long, List<Long>> foodToSpecIds = rels.stream()
+                .collect(Collectors.groupingBy(FoodSpecRelation::getFoodId,
+                        Collectors.mapping(FoodSpecRelation::getSpecsId, Collectors.toList())));
+        List<Long> specsIds = rels.stream().map(FoodSpecRelation::getSpecsId).distinct().collect(Collectors.toList());
+        List<FoodSpecs> specsList = foodSpecsService.list(new LambdaQueryWrapper<FoodSpecs>()
+                .in(FoodSpecs::getId, specsIds)
+                .eq(FoodSpecs::getIsOpen, true)
+                .eq(FoodSpecs::getIsDelete, false)
+                .orderByAsc(FoodSpecs::getSort));
+        if (specsList.isEmpty()) {
+            return result;
+        }
+        List<Long> enabledSpecIds = specsList.stream().map(FoodSpecs::getId).collect(Collectors.toList());
+        List<FoodSpecsValue> values = foodSpecsValueService.list(new LambdaQueryWrapper<FoodSpecsValue>()
+                .in(FoodSpecsValue::getParentId, enabledSpecIds)
+                .eq(FoodSpecsValue::getIsOpen, true)
+                .orderByAsc(FoodSpecsValue::getId));
+        Map<Long, List<FoodSpecsValue>> valueMap = values.stream()
+                .collect(Collectors.groupingBy(FoodSpecsValue::getParentId));
+        Map<Long, FoodSpecs> specsMap = specsList.stream().collect(Collectors.toMap(FoodSpecs::getId, sp -> sp));
+        for (Map.Entry<Long, List<Long>> entry : foodToSpecIds.entrySet()) {
+            List<FoodSpecs> fsList = new ArrayList<>();
+            for (Long sid : entry.getValue()) {
+                FoodSpecs src = specsMap.get(sid);
+                if (src != null) {
+                    FoodSpecs copy = new FoodSpecs();
+                    copy.setId(src.getId());
+                    copy.setTitle(src.getTitle());
+                    copy.setType(src.getType());
+                    copy.setState(src.getState());
+                    copy.setLanguage(src.getLanguage());
+                    copy.setRemark(src.getRemark());
+                    copy.setMdId(src.getMdId());
+                    copy.setSort(src.getSort());
+                    copy.setIsOpen(src.getIsOpen());
+                    copy.setIsDelete(src.getIsDelete());
+                    copy.setObjects(valueMap.getOrDefault(src.getId(), new ArrayList<>()));
+                    fsList.add(copy);
+                }
+            }
+            result.put(entry.getKey(), fsList);
+        }
+        return result;
+    }
+
+    /**
+     * 由结构化规格构建兼容的 food_sku JSON 数组(明细字段名 foodSpecsItems)
+     */
+    private JSONArray buildFoodSkuArray(List<FoodSpecs> fsList) {
+        JSONArray skuArr = new JSONArray();
+        if (fsList == null || fsList.isEmpty()) {
+            return skuArr;
+        }
+        for (FoodSpecs s : fsList) {
+            skuArr.add(JSONObject.toJSON(s));
+        }
+        return skuArr;
+    }
+
+    private void fillStallLocation(PosStore store) {
+        if (store != null && store.getIsStall() != null && store.getIsStall() == 1
+                && store.getNightMarketId() != null) {
+            InfoUser nightMarket = infoUserService.getById(store.getNightMarketId());
+            if (nightMarket != null) {
+                store.setLongitude(nightMarket.getLongitude());
+                store.setLatitude(nightMarket.getLatitude());
+            }
+        }
+    }
+}

+ 152 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -10,6 +10,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.order.SelfDeliveryService;
+import com.ruoyi.app.mendian.dto.SelfDeliveryConfigInput;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.utils.DateUtil;
 import com.ruoyi.app.utils.ImageCompressUtils;
@@ -60,11 +62,19 @@ import java.util.stream.Collectors;
 public class PosStoreController extends BaseController {
     @Autowired
     private IPosStoreService posStoreService;
+    /** 支付方式闸门(031):bankInfo 展示前置判定。 */
+    @Autowired
+    private com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService;
+    /** 收款银行卡(031 列表化):bankInfo 数据源。 */
+    @Autowired
+    private com.ruoyi.system.mapper.InfoBankCardMapper infoBankCardMapper;
 
     @Autowired
     private PosStoreMapper posStoreMapper;
     @Autowired
     private IPosCollectService posCollectService;
+    @Autowired  //029 商家自配送:配置解析/保存
+    private SelfDeliveryService selfDeliveryService;
     @Autowired
     private IPosFenleiService posFenleiService;
 
@@ -237,10 +247,7 @@ public class PosStoreController extends BaseController {
 
     }
 
-    /**
-     * 商家收款账户(線下轉賬收银台展示用):门店 到 所属商家主账号 到 审核资料银行三字段
-     * 商家级数据,连锁门店共享同一收款账户;任一字段为空返回 data=null,客户端据此隐藏線下支付选项
-     */
+    /** 商家线下转账收款账户:普通店按主账号、夜市摊位按 store_id 读取启用卡。 */
     @Anonymous
     @GetMapping("/bankInfo")
     public AjaxResult bankInfo(@RequestParam Integer id) {
@@ -248,15 +255,36 @@ public class PosStoreController extends BaseController {
         if (store == null || store.getUserId() == null) {
             return success(null);
         }
-        InfoUser merchant = infoUserService.selectInfoUserByUserId(store.getUserId());
-        if (merchant == null || StringUtils.isBlank(merchant.getBankAccountName())
-                || StringUtils.isBlank(merchant.getBankName()) || StringUtils.isBlank(merchant.getBankAccountNo())) {
+        // 031:先套闸门(平台开 线下转账×商家 ∩ 商家接受),不满足按无信息返回,老客户端自然隐藏
+        try {
+            paymentMethodGateService.assertUsable("6",
+                    com.ruoyi.system.service.PaymentMethodGateService.GateScope.MERCHANT,
+                    store.getId().longValue(), store.getUserId());
+        } catch (com.ruoyi.common.exception.ServiceException gateRejected) {
+            return success(null);
+        }
+        // 031:数据源改收款银行卡启用卡(027 存量三字段已迁移);判据从"三字段齐全"改为"存在启用卡"。
+        // 防御多行启用脏数据:取第一张而非 selectOne(多行会抛底层异常)。
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.ruoyi.system.domain.InfoBankCard> cardQuery =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        if (Integer.valueOf(1).equals(store.getIsStall())) {
+            // 摊位的 user_id 是夜市主;只读取本摊位启用卡,避免不同摊位串用收款账户。
+            cardQuery.eq(com.ruoyi.system.domain.InfoBankCard::getStoreId, store.getId().longValue());
+        } else {
+            cardQuery.eq(com.ruoyi.system.domain.InfoBankCard::getUserId, store.getUserId());
+        }
+        java.util.List<com.ruoyi.system.domain.InfoBankCard> cards = infoBankCardMapper.selectList(
+                cardQuery.eq(com.ruoyi.system.domain.InfoBankCard::getIsActive, 1)
+                        .orderByDesc(com.ruoyi.system.domain.InfoBankCard::getId)
+                        .last("LIMIT 1"));
+        if (cards.isEmpty()) {
             return success(null);
         }
+        com.ruoyi.system.domain.InfoBankCard card = cards.get(0);
         JSONObject data = new JSONObject();
-        data.put("accountName", merchant.getBankAccountName().trim());
-        data.put("bankName", merchant.getBankName().trim());
-        data.put("accountNo", merchant.getBankAccountNo().trim());
+        data.put("accountName", card.getAccountName().trim());
+        data.put("bankName", card.getBankName().trim());
+        data.put("accountNo", card.getAccountNo().trim());
         return success(data);
     }
 
@@ -267,7 +295,7 @@ public class PosStoreController extends BaseController {
                                    @RequestParam BigDecimal latitude,
                                    @RequestParam Integer page,
                                    @RequestParam String area,
-                                   @RequestParam Integer juli,
+                                   @RequestParam(required = false) Integer juli,  //距离筛选(千米),可不传;不传则不过滤
                                    @RequestParam(defaultValue = "") Integer sort,
                                    @RequestParam(defaultValue = "zh-TW", required = false) String language) {
         List<PosStore> list;
@@ -281,8 +309,8 @@ public class PosStoreController extends BaseController {
 
         // total 必须是符合条件总数,不能用当前页行数充当,否则客户端按 total 判断会误判"已加载完"导致翻页失效
         long total = sort == null
-                ? posStoreMapper.getmdlistCount("%" + area + "%")
-                : posStoreMapper.getdaidiqCount("%" + area + "%", sort);
+                ? posStoreMapper.getmdlistCount(longitude, latitude, "%" + area + "%", juli)
+                : posStoreMapper.getdaidiqCount(longitude, latitude, "%" + area + "%", sort, juli);
         Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, total);
         return success(result);
 
@@ -473,6 +501,117 @@ public class PosStoreController extends BaseController {
         merchantStoreAccessService.requireStoreAccess(loginUserId, storeId);
     }
 
+    /**
+     * (商家端)获取门店自配送配置(029):普通门店返回自身;摊位返回所辖夜市配置(前端只读展示)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getMdSelfDeliveryHours")
+    public AjaxResult getMdSelfDeliveryHours(@RequestHeader String token, @RequestParam Integer mdId) {
+        if (mdId == null) {
+            throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
+        }
+        PosStore store = posStoreService.getById(mdId);
+        if (store == null) {
+            throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
+        }
+        // 摊位解析到夜市主门店行(spec FR-002:摊位配送方式跟随夜市);解析不到按未配置返回
+        PosStore configStore = selfDeliveryService.resolveConfigStore(store);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("enabled", configStore != null && Integer.valueOf(1).equals(configStore.getSelfDeliveryEnabled()));
+        data.put("mode", configStore == null ? null : configStore.getSelfDeliveryMode());
+        data.put("hours", configStore == null ? new ArrayList<>()
+                : selfDeliveryService.listHours(Long.valueOf(configStore.getId())));
+        data.put("configMdId", configStore == null ? null : configStore.getId());
+        return success(data);
+    }
+
+    /**
+     * (商家端)保存门店自配送配置(029):摊位无配置权;夜市主门店行仅夜市主本人可保存;
+     * 2026-09-10 范围调整:普通商家门店不可设置自配送(PC 端功能同步隐藏)
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/saveMdSelfDeliveryHours")
+    public AjaxResult saveMdSelfDeliveryHours(@RequestHeader String token,
+                                              @RequestBody SelfDeliveryConfigInput input) {
+        Long userId = AuthContext.requireUserId();
+        if (input == null || input.getMdId() == null) {
+            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.hours.invalid"));
+        }
+        PosStore store = posStoreService.getById(input.getMdId());
+        if (store == null) {
+            throw new ServiceException(MessageUtils.message("no.mendian.not.exist"));
+        }
+        // 摊位配送方式由夜市主统一设置(spec FR-002)
+        if (Integer.valueOf(1).equals(store.getIsStall())) {
+            throw new ServiceException(MessageUtils.message("no.store.stall.selfdelivery.readonly"));
+        }
+        if (Integer.valueOf(1).equals(store.getIsNightMarket())) {
+            // 夜市场景:配置主体是夜市主自己的门店行,仅夜市主本人可保存
+            if (!userId.equals(store.getUserId())) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+        } else {
+            // 2026-09-10 范围调整:自配送仅夜市主可配置,普通商家门店一律拒绝
+            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.nightmarket.only"));
+        }
+        selfDeliveryService.saveConfig(input);
+        return success();
+    }
+
+    /**
+     * (商家端 PC)读取当前登录夜市主自己的自配送配置(029)。
+     * 夜市主账号只有 userId、不持有门店 ID,后端按归属反查夜市主门店行;
+     * 2026-10-08 变更:夜市主尚未建锚点行时返回默认未启用配置(首次保存时补建),
+     * 非夜市主(user_type!=3)统一按"未找到夜市门店"拒绝。
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getMyNightMarketSelfDelivery")
+    public AjaxResult getMyNightMarketSelfDelivery(@RequestHeader String token) {
+        Long userId = AuthContext.requireUserId();
+        requireNightMarketOwner(userId);
+        PosStore configStore = selfDeliveryService.resolveNightMarketStoreByOwner(userId);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("enabled", configStore != null && Integer.valueOf(1).equals(configStore.getSelfDeliveryEnabled()));
+        data.put("mode", configStore == null ? null : configStore.getSelfDeliveryMode());
+        data.put("hours", configStore == null ? new ArrayList<>()
+                : selfDeliveryService.listHours(Long.valueOf(configStore.getId())));
+        return success(data);
+    }
+
+    /**
+     * (商家端 PC)保存当前登录夜市主自己的自配送配置(029)。
+     * 配置主体由后端反查决定,前端不传也不信任 mdId;校验与覆盖式保存复用 saveConfig;
+     * 2026-10-08 变更:夜市主锚点行缺失时自动补建(纯配置行),后建摊位照常生效。
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/saveMyNightMarketSelfDelivery")
+    public AjaxResult saveMyNightMarketSelfDelivery(@RequestHeader String token,
+                                                    @RequestBody SelfDeliveryConfigInput input) {
+        Long userId = AuthContext.requireUserId();
+        if (input == null) {
+            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.hours.invalid"));
+        }
+        InfoUser owner = requireNightMarketOwner(userId);
+        PosStore configStore = selfDeliveryService.resolveOrCreateNightMarketStoreByOwner(owner);
+        // 主体由后端决定:覆盖前端传入的 mdId,防止越权写其他门店
+        input.setMdId(configStore.getId());
+        selfDeliveryService.saveConfig(input);
+        return success();
+    }
+
+    /** 029 My 接口门槛:仅夜市主(user_type=3)可用,其余一律按"未找到夜市门店"拒绝。 */
+    private InfoUser requireNightMarketOwner(Long userId) {
+        InfoUser owner = infoUserService.selectInfoUserByUserId(userId);
+        if (owner == null || !"3".equals(owner.getUserType())) {
+            throw new ServiceException(MessageUtils.message("no.store.selfdelivery.market.missing"));
+        }
+        return owner;
+    }
+
     //返回营业时间(按星期分组)
     private List<StoreOperatOutput> getStoreOperatOutput(List<OperatingHours> list, Long mdId){
         List<StoreOperatOutput> data= new ArrayList<>();

+ 5 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEnrichService.java

@@ -15,6 +15,7 @@ import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.service.IOperatingHoursService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosReviewService;
+import com.ruoyi.system.service.StallQualificationGateService;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
@@ -36,6 +37,8 @@ public class PosStoreEnrichService {
     private IOperatingHoursService operatingHoursService;
     @Autowired
     private PosFoodMapper posFoodMapper;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
 
     /**
      * 为门店列表设置评分、月售、营业状态。
@@ -98,7 +101,8 @@ public class PosStoreEnrichService {
                     .filter(x -> store.getId() != null && store.getId().equals(x.getMdId().intValue()))
                     .collect(Collectors.toList());
             dayang = OperatingUtil.storeIsOperatingOfDay(mdHours);
-            if (!dayang) {
+            // 未审核摊位显示休息中;新订单接口仍独立校验,不能信任列表状态。
+            if (!dayang || !stallQualificationGateService.canOperate(store)) {
                 store.setState(1L);
             }
         }

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/FoodAuditRequest.java

@@ -0,0 +1,11 @@
+package com.ruoyi.app.mendian.dto;
+
+import lombok.Data;
+
+/** 平台商品审核只接收商品 ID 与审核状态,不允许借审核修改商品内容或门店归属。 */
+@Data
+public class FoodAuditRequest {
+    private Long id;
+    /** 1=审核通过,0=撤销审核;与商家的商品上下架/推荐字段分开。 */
+    private String toExamine;
+}

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/SelfDeliveryConfigInput.java

@@ -0,0 +1,35 @@
+package com.ruoyi.app.mendian.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 门店自配送配置保存入参(029 商家自配送)。
+ * 仅承载请求数据;格式校验在 SelfDeliveryService、权限校验在 Controller(项目 Controller 参数规范:DTO 无校验注解)。
+ */
+@Data
+public class SelfDeliveryConfigInput {
+
+    /** 配置主体门店id(普通门店=自身;夜市=夜市主门店行;摊位不允许保存) */
+    private Integer mdId;
+
+    /** 自配送开关 */
+    private Boolean enabled;
+
+    /** 时段模式:1=全天,2=自定义按星期 */
+    private Integer mode;
+
+    /** 自定义时段列表(mode=2 时必填非空) */
+    private List<SelfDeliveryHourInput> hours;
+
+    @Data
+    public static class SelfDeliveryHourInput {
+        /** 星期几:1=周一..7=周日 */
+        private Integer dayOfWeek;
+        /** 开始时刻 HH:mm(含) */
+        private String startTime;
+        /** 结束时刻 HH:mm(不含,必须大于开始时刻) */
+        private String endTime;
+    }
+}

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketStoreController.java

@@ -17,6 +17,7 @@ import com.ruoyi.system.service.IPosCollectService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosReviewService;
 import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.StallQualificationGateService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -40,6 +41,8 @@ public class NightMarketStoreController extends BaseController {
 
     @Autowired
     private IPosStoreService posStoreService;
+    @Autowired
+    private StallQualificationGateService stallQualificationGateService;
 
     @Autowired
     private IPosCollectService posCollectService;
@@ -164,6 +167,8 @@ public class NightMarketStoreController extends BaseController {
         List<PosFood> foods = posFoodMapper.selectFoodsByStoreId(storeId, langCode);
 
         JSONObject result = new JSONObject();
+        // 详情明确返回不可营业状态,与列表和下单资格保持一致。
+        result.put("state", stallQualificationGateService.canOperate(store) ? store.getState() : 1L);
         result.put("id", store.getId());
         result.put("posName", store.getPosName());
         result.put("image", store.getImage());

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -102,6 +102,10 @@ public class OmgPaymentCreateService {
                 OmgPaymentAttempt attempt = attempts.createCreated(order.getDdId(), merchantTradeNo,
                         order.getStoreId(), credential.getMerchantId(), order.getAmount(),
                         credential.getHashKey(), credential.getHashIv());
+                if (OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL.equals(form.gatewayUrl())
+                        && attempts.markProductionEnvironment(attempt.getId()) != 1) {
+                    throw new IllegalStateException("OMG production environment snapshot failed");
+                }
                 OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
                 return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
                         order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java

@@ -15,6 +15,8 @@ import java.util.Map;
 public class OmgPaymentFormFactory {
     public static final String STAGE_GATEWAY_URL =
             "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5";
+    public static final String PRODUCTION_GATEWAY_URL =
+            "https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5";
     private static final DateTimeFormatter TRADE_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
 
@@ -57,7 +59,8 @@ public class OmgPaymentFormFactory {
         Map<String, String> signedFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
         fields.put("CheckMacValue", signer.sign(signedFields, hashKey, hashIv));
         Map<String, String> responseFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
-        return new OmgPaymentForm(STAGE_GATEWAY_URL, responseFields);
+        return new OmgPaymentForm(properties.isProduction() ? PRODUCTION_GATEWAY_URL : STAGE_GATEWAY_URL,
+                responseFields);
     }
 
     private static String safeOrderReference(String orderId) {

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java

@@ -16,5 +16,15 @@ public record OmgPaymentGatewayFacts(
         Date paymentDate,
         Date tradeDate,
         BigDecimal paymentTypeChargeFee,
-        Integer simulatePaid) {
+        Integer simulatePaid,
+        String creditRefundId) {
+    public OmgPaymentGatewayFacts(OmgPaymentFactSource source, String merchantId,
+                                  String merchantTradeNo, int amount, int resultCode,
+                                  String resultMessage, String tradeNo, String paymentType,
+                                  Date paymentDate, Date tradeDate, BigDecimal paymentTypeChargeFee,
+                                  Integer simulatePaid) {
+        this(source, merchantId, merchantTradeNo, amount, resultCode, resultMessage,
+                tradeNo, paymentType, paymentDate, tradeDate, paymentTypeChargeFee,
+                simulatePaid, null);
+    }
 }

+ 27 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.order.MerchantNotificationRouter;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
+import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
-import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.utils.LocaleUtils;
 import com.ruoyi.common.utils.MessageUtils;
@@ -38,6 +40,8 @@ import java.util.Locale;
 /** Verifies and applies one final OMG payment notification transactionally. */
 @Service
 public class OmgPaymentNotifyService {
+    @Autowired(required = false)
+    private OmgPaymentRefundService refundService;
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
     private static final int STATUS_PAID = 1;
     private static final int STATUS_SUPERSEDED = 3;
@@ -60,6 +64,8 @@ public class OmgPaymentNotifyService {
     private IInfoUserService infoUserService;
     @Autowired(required = false)
     private PushEventService pushEventService;
+    @Autowired(required = false)
+    private BusinessPushService businessPushService;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -105,7 +111,8 @@ public class OmgPaymentNotifyService {
                 OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
                 attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
                 facts.paymentType, facts.paymentDate, facts.tradeDate,
-                facts.paymentTypeChargeFee, facts.simulatePaid);
+                facts.paymentTypeChargeFee, facts.simulatePaid,
+                request.value("gwsr"));
         return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
     }
 
@@ -166,10 +173,18 @@ public class OmgPaymentNotifyService {
 
         requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
         attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
-        if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
-            requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
-            openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
-            pushPaymentSuccessToUser(order);
+        boolean canFulfill = Long.valueOf(0L).equals(order.getPayStatus())
+                && order.getState() != null && order.getState() != 4L
+                && ("2".equals(order.getPayType()) || "5".equals(order.getPayType()));
+        if (canFulfill) {
+            if (attempts.markOrderPaid(attempt.getDdId()) == 1) {
+                openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
+                pushPaymentSuccessToUser(order);
+            } else if (refundService != null) {
+                refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
+            }
+        } else if (refundService != null) {
+            refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
@@ -229,10 +244,12 @@ public class OmgPaymentNotifyService {
                 Locale userLocale = LocaleUtils.getUserLocale(user.getUserId());
                 String title = MessageUtils.message("no.message.push.message", userLocale);
                 String content = MessageUtils.message("no.message.push.payment.success", userLocale);
-                String body = OrderPushBodyDto.getJson(order.getDdId(), String.valueOf(order.getState()), 0);
+                // 保留支付通知 type=0,不改变核销/幂等,仅增加前台用户语音场景。
+                String body = OrderPushBodyDto.getUserJson(order.getDdId(), String.valueOf(order.getState()),
+                        0, 1, UserNotificationScene.PAYMENT_SUCCEEDED);
                 pushEventService.PublisherEvent(user.getUserId(), title, content, body);
-                if (user.getCid() != null && !user.getCid().trim().isEmpty()) {
-                    new PayPush().apppush(user.getCid(), title, content, body);
+                if (businessPushService != null) {
+                    businessPushService.send(RecipientApp.USER, user.getUserId(), user.getCid(), title, content, body);
                 }
             } catch (Exception exception) {
                 log.error("OMG payment success push failed, ddId={}", order.getDdId(), exception);
@@ -315,6 +332,7 @@ public class OmgPaymentNotifyService {
         OmgPaymentAttempt update = new OmgPaymentAttempt();
         update.setId(attempt.getId());
         update.setTradeNo(facts.tradeNo());
+        update.setCreditRefundId(facts.creditRefundId());
         update.setRtnCode(facts.resultCode());
         update.setRtnMsg(limit(facts.resultMessage(), 200));
         update.setPaymentType(limit(facts.paymentType(), 20));

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java

@@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.stereotype.Component;
 
 import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
 
 @Component
 @ConfigurationProperties(prefix = "omgpay")
@@ -14,6 +16,21 @@ public class OmgPaymentProperties {
     private String returnUrl;
     private String orderResultUrl;
     private String appReturnUrl;
+    /** TEST is the safe default. Production credentials must explicitly enable PRODUCTION. */
+    private String environment = "TEST";
+    /** MerchantID -> CreditCheckCode, supplied through protected deployment configuration. */
+    private Map<String, String> creditCheckCodes = new HashMap<>();
+
+    public boolean isProduction() {
+        return "PRODUCTION".equalsIgnoreCase(environment);
+    }
+
+    public String getEnvironment() { return environment; }
+    public void setEnvironment(String environment) { this.environment = environment; }
+    public Map<String, String> getCreditCheckCodes() { return creditCheckCodes; }
+    public void setCreditCheckCodes(Map<String, String> creditCheckCodes) {
+        this.creditCheckCodes = creditCheckCodes == null ? new HashMap<>() : creditCheckCodes;
+    }
 
     public String getReturnUrl() {
         return returnUrl;

+ 15 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java

@@ -10,6 +10,7 @@ import org.apache.http.impl.client.HttpClients;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.http.util.EntityUtils;
 import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Autowired;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -22,6 +23,8 @@ import java.util.Map;
 public class OmgPaymentQueryGateway {
     static final String STAGE_QUERY_URL =
             "https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
+    static final String PRODUCTION_QUERY_URL =
+            "https://payment.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
     private static final int MAX_RESPONSE_BYTES = 32 * 1024;
     private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
             .setConnectTimeout(5_000)
@@ -29,10 +32,21 @@ public class OmgPaymentQueryGateway {
             .setSocketTimeout(10_000)
             .build();
 
+    private final OmgPaymentProperties properties;
+
+    @Autowired
+    public OmgPaymentQueryGateway(OmgPaymentProperties properties) {
+        this.properties = properties;
+    }
+
     public String query(Map<String, String> fields) {
+        return query(fields, properties.isProduction() ? "PRODUCTION" : "TEST");
+    }
+
+    public String query(Map<String, String> fields, String environment) {
         List<NameValuePair> form = new ArrayList<>();
         fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
-        HttpPost post = new HttpPost(STAGE_QUERY_URL);
+        HttpPost post = new HttpPost("PRODUCTION".equals(environment) ? PRODUCTION_QUERY_URL : STAGE_QUERY_URL);
         post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
         try (CloseableHttpClient client = HttpClients.custom()
                 .setDefaultRequestConfig(REQUEST_CONFIG)

+ 5 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -99,7 +99,9 @@ public class OmgPaymentQueryService {
     private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId,
                                                   Long storeId, String source) {
         try {
-            Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
+            Map<String, String> response = parser.parse(attempt.getGatewayEnvironment() == null
+                    ? gateway.query(buildRequest(attempt))
+                    : gateway.query(buildRequest(attempt), attempt.getGatewayEnvironment()));
             validateResponse(response, attempt);
             String tradeStatus = response.get("TradeStatus");
             String status = synchronizeIfFinal(tradeStatus, response, attempt);
@@ -223,7 +225,8 @@ public class OmgPaymentQueryService {
                 attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
                 tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
                 parseDate(fields.get("PaymentDate"), paid),
-                parseDate(fields.get("TradeDate"), paid), fee, null);
+                parseDate(fields.get("TradeDate"), paid), fee, null,
+                blankToNull(fields.get("gwsr")));
     }
 
     private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {

+ 295 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java

@@ -1,43 +1,317 @@
 package com.ruoyi.app.omgpay;
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONArray;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.AUTH_REQUIRED;
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.ORDER_REQUIRED;
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT;
+import java.time.LocalTime;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 
-/**
- * Test-stage refund boundary. OMG publishes no refund endpoint for its test environment,
- * so this service must fail closed before reading payment facts or changing local state.
- */
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
+
+/** 持久化 OMG 退款意图,并按正式信用卡交易事实推进退款。 */
 @Service
 public class OmgPaymentRefundService {
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundService.class);
+    private final OmgPaymentProperties properties;
+    private final IOmgPaymentAttemptService attempts;
+    private final JdbcTemplate jdbc;
+    private final OmgCheckMacSigner signer;
+    private final OmgRefundGateway gateway;
+    private final OmgQueryResponseParser parser;
+    private final Clock clock;
+
+    /** 保留测试环境边界测试的无参构造。 */
+    public OmgPaymentRefundService() {
+        this(new OmgPaymentProperties(), null, null, null, null, null);
+    }
+
+    @Autowired
+    public OmgPaymentRefundService(OmgPaymentProperties properties,
+                                   IOmgPaymentAttemptService attempts,
+                                   JdbcTemplate jdbc,
+                                   OmgCheckMacSigner signer,
+                                   OmgRefundGateway gateway,
+                                   OmgQueryResponseParser parser) {
+        this(properties, attempts, jdbc, signer, gateway, parser, Clock.systemUTC());
+    }
+
+    OmgPaymentRefundService(OmgPaymentProperties properties,
+                            IOmgPaymentAttemptService attempts, JdbcTemplate jdbc,
+                            OmgCheckMacSigner signer, OmgRefundGateway gateway,
+                            OmgQueryResponseParser parser, Clock clock) {
+        this.properties = properties;
+        this.attempts = attempts;
+        this.jdbc = jdbc;
+        this.signer = signer;
+        this.gateway = gateway;
+        this.parser = parser;
+        this.clock = clock;
+    }
 
     public Void refund(Long userId, String orderId) {
         if (userId == null) {
-            throw business(AUTH_REQUIRED);
+            throw new OmgPaymentBusinessException(AUTH_REQUIRED);
+        }
+        if (orderId == null || orderId.isBlank() || orderId.trim().length() > 64) {
+            throw new OmgPaymentBusinessException(ORDER_REQUIRED);
+        }
+        if (!properties.isProduction()) {
+            log.warn("OMG refund unavailable in test environment orderId={}",
+                    OmgPaymentController.safeLogOrderId(orderId));
+            throw new OmgPaymentBusinessException(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrder(orderId.trim());
+        if (order == null || !userId.equals(order.getUserId())
+                || !Long.valueOf(4L).equals(order.getState())
+                || attempts.selectPaidByDdId(orderId.trim()) == null) {
+            throw new OmgPaymentBusinessException(PAYMENT_REFUND_FAILED);
         }
-        String normalizedOrderId = normalizeOrderId(orderId);
-        log.warn("OMG refund blocked environment=TEST, orderId={}, userId={}, reason=GATEWAY_UNAVAILABLE",
-                OmgPaymentController.safeLogOrderId(normalizedOrderId), userId);
-        throw business(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
+        createRefundIntent(orderId.trim(), "USER_REQUEST");
+        return null;
     }
 
-    private static String normalizeOrderId(String orderId) {
-        if (orderId == null || orderId.isBlank()) {
-            throw business(ORDER_REQUIRED);
+    /** 与取消/迟到回调同事务,按支付尝试唯一键幂等写入。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void createRefundIntent(String orderId, String source) {
+        jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                        + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                        + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
+                        + "FROM pos_order_omg_attempt WHERE dd_id = ? AND attempt_status = 1",
+                source, orderId);
+        Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE dd_id = ?",
+                Integer.class, orderId);
+        if (count == null || count == 0) {
+            throw new IllegalStateException("OMG captured payment missing for refund");
         }
-        String normalized = orderId.trim();
-        if (normalized.length() > 64) {
-            throw business(ORDER_REQUIRED);
+    }
+
+    /** 迟到或重复支付只退本次实收,不能误退订单上另一笔有效付款。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void createRefundIntentForAttempt(Long attemptId, String source) {
+        int inserted = jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                        + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                        + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
+                        + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
+                source, attemptId);
+        if (inserted == 0) {
+            Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE attempt_id=?",
+                    Integer.class, attemptId);
+            if (existing == null || existing == 0) {
+                throw new IllegalStateException("OMG captured attempt missing for refund");
+            }
         }
-        return normalized;
     }
 
-    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
-        return new OmgPaymentBusinessException(code);
+    /** 补偿已取消或已切换渠道后的迟到实收,唯一键保证重复扫描幂等。 */
+    public void ensureOrphanedCapturedIntents() {
+        jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                + "SELECT a.id, a.dd_id, 'LATE_PAYMENT_SCAN', 'PENDING', NOW(), NOW() "
+                + "FROM pos_order_omg_attempt a JOIN pos_order o ON o.dd_id=a.dd_id "
+                + "WHERE a.attempt_status=1 AND (o.state=4 OR o.pay_type NOT IN ('2','5')) "
+                + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_refund r WHERE r.attempt_id=a.id)");
+    }
+
+    /** 退款事实已确认后补偿订单支付状态,避免单次任务在两条更新之间中断。 */
+    public void reconcileRefundedOrderStatuses() {
+        jdbc.update("UPDATE pos_order o SET o.pay_status=2 WHERE o.state=4 AND o.pay_status=1 "
+                + "AND o.pay_type IN ('2','5') "
+                + "AND EXISTS (SELECT 1 FROM pos_order_omg_attempt a WHERE a.dd_id=o.dd_id AND a.attempt_status=1) "
+                + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
+                + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
+                + "WHERE a.dd_id=o.dd_id AND a.attempt_status=1 "
+                + "AND (r.id IS NULL OR r.status!='REFUNDED'))");
+    }
+
+    public List<Long> pendingIds(int limit) {
+        return jdbc.queryForList("SELECT id FROM pos_order_omg_refund "
+                        + "WHERE status IN ('PENDING', 'ACTION_UNKNOWN') "
+                        + "AND (lease_until IS NULL OR lease_until < NOW()) ORDER BY id LIMIT ?",
+                Long.class, limit);
     }
+
+    /** 不确定结果必须先查询事实;同一状态不重放同一退款动作。 */
+    public void processOne(Long refundId) {
+        if (!properties.isProduction()) {
+            jdbc.update("UPDATE pos_order_omg_refund SET last_error='TEST_ENVIRONMENT', update_time=NOW() "
+                    + "WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN')", refundId);
+            return;
+        }
+        int claimed = jdbc.update("UPDATE pos_order_omg_refund SET lease_until=DATE_ADD(NOW(), INTERVAL 2 MINUTE), "
+                        + "update_time=NOW() WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN') "
+                        + "AND (lease_until IS NULL OR lease_until < NOW())", refundId);
+        if (claimed != 1) {
+            return;
+        }
+        boolean actionStarted = false;
+        String priorStatus = "PENDING";
+        try {
+            RefundRow row = jdbc.queryForObject("SELECT attempt_id, status, last_action, last_gateway_status "
+                            + "FROM pos_order_omg_refund WHERE id=?", (rs, n) ->
+                    new RefundRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4)), refundId);
+            priorStatus = row.status();
+            OmgPaymentAttempt attempt = loadAttempt(row.attemptId());
+            String checkCode = properties.getCreditCheckCodes().get(attempt.getMerchantId());
+            if (!"PRODUCTION".equals(attempt.getGatewayEnvironment())) {
+                defer(refundId, "PAYMENT_CREATED_IN_TEST_ENVIRONMENT", "MANUAL_REVIEW");
+                return;
+            }
+            if (attempt.getCreditRefundId() == null || !attempt.getCreditRefundId().matches("[0-9]+")
+                    || checkCode == null || !checkCode.matches("[0-9]+") || attempt.getTradeNo() == null) {
+                defer(refundId, "MISSING_CREDIT_REFUND_CREDENTIAL", priorStatus);
+                return;
+            }
+            String gatewayStatus = creditStatus(attempt, checkCode);
+            if (List.of("已取消", "銀行已追回", "FULL_REFUND_CONFIRMED").contains(gatewayStatus)) {
+                markRefunded(refundId, attempt);
+                return;
+            }
+            String action = nextAction(gatewayStatus);
+            if (action == null) {
+                defer(refundId, "GATEWAY_STATUS_REQUIRES_REVIEW", "MANUAL_REVIEW");
+                return;
+            }
+            if (row.lastAction() != null
+                    && (row.lastAction().equals(action) || Objects.equals(row.lastGatewayStatus(), gatewayStatus))) {
+                defer(refundId, "WAITING_FOR_GATEWAY_STATE_CHANGE", "ACTION_UNKNOWN");
+                return;
+            }
+            if (inAutoCloseWindow()) {
+                defer(refundId, "AUTO_CLOSE_WINDOW", priorStatus);
+                return;
+            }
+            // 先持久化动作可能已发出,超时后只查事实。
+            jdbc.update("UPDATE pos_order_omg_refund SET status='ACTION_UNKNOWN', last_action=?, "
+                            + "last_gateway_status=?, last_action_time=NOW(), attempt_count=attempt_count+1, "
+                            + "last_error=NULL, update_time=NOW() WHERE id=?",
+                    action, gatewayStatus, refundId);
+            actionStarted = true;
+            Map<String, String> response = parser.parse(gateway.doAction(actionRequest(attempt, action)));
+            if (!attempt.getMerchantId().equals(response.get("MerchantID"))
+                    || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
+                    || !attempt.getTradeNo().equals(response.get("TradeNo"))) {
+                defer(refundId, "REFUND_RESPONSE_IDENTITY_MISMATCH", "MANUAL_REVIEW");
+            } else if (!"1".equals(response.get("RtnCode"))) {
+                defer(refundId, "REFUND_ACTION_REJECTED", "MANUAL_REVIEW");
+            }
+        } catch (Exception error) {
+            log.error("OMG refund result uncertain refundId={}", refundId, error);
+            defer(refundId, "GATEWAY_RESULT_UNKNOWN", actionStarted ? "ACTION_UNKNOWN" : priorStatus);
+        } finally {
+            jdbc.update("UPDATE pos_order_omg_refund SET lease_until=NULL WHERE id=?", refundId);
+        }
+    }
+
+    private OmgPaymentAttempt loadAttempt(Long attemptId) {
+        return jdbc.queryForObject("SELECT id, dd_id, merchant_trade_no, merchant_id, amount, "
+                        + "hash_key_snapshot, hash_iv_snapshot, trade_no, credit_refund_id, gateway_environment "
+                        + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
+                (rs, n) -> {
+                    OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+                    attempt.setId(rs.getLong("id"));
+                    attempt.setDdId(rs.getString("dd_id"));
+                    attempt.setMerchantTradeNo(rs.getString("merchant_trade_no"));
+                    attempt.setMerchantId(rs.getString("merchant_id"));
+                    attempt.setAmount(rs.getInt("amount"));
+                    attempt.setHashKeySnapshot(rs.getString("hash_key_snapshot"));
+                    attempt.setHashIvSnapshot(rs.getString("hash_iv_snapshot"));
+                    attempt.setTradeNo(rs.getString("trade_no"));
+                    attempt.setCreditRefundId(rs.getString("credit_refund_id"));
+                    attempt.setGatewayEnvironment(rs.getString("gateway_environment"));
+                    return attempt;
+                }, attemptId);
+    }
+
+    private String creditStatus(OmgPaymentAttempt attempt, String checkCode) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("CreditRefundId", attempt.getCreditRefundId());
+        fields.put("CreditAmount", String.valueOf(attempt.getAmount()));
+        fields.put("CreditCheckCode", checkCode);
+        fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        JSONObject response = JSON.parseObject(gateway.queryCredit(fields));
+        if (response == null || (response.getString("RtnMsg") != null
+                && !response.getString("RtnMsg").isBlank())) {
+            throw new IllegalStateException("OMG credit query rejected");
+        }
+        JSONObject value = response.getJSONObject("RtnValue");
+        if (value == null || !attempt.getCreditRefundId().equals(value.getString("TradeID"))
+                || attempt.getAmount() != value.getIntValue("amount")
+                || value.getString("status") == null) {
+            throw new IllegalStateException("OMG credit query identity mismatch");
+        }
+        JSONArray closeData = value.getJSONArray("close_data");
+        if (closeData != null) {
+            long recovered = 0;
+            for (int i = 0; i < closeData.size(); i++) {
+                JSONObject item = closeData.getJSONObject(i);
+                if (item != null && "銀行已追回".equals(item.getString("status"))) {
+                    recovered += item.getLongValue("amount");
+                }
+            }
+            if (recovered == attempt.getAmount()) {
+                return "FULL_REFUND_CONFIRMED";
+            }
+        }
+        return value.getString("status");
+    }
+
+    private Map<String, String> actionRequest(OmgPaymentAttempt attempt, String action) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("MerchantTradeNo", attempt.getMerchantTradeNo());
+        fields.put("TradeNo", attempt.getTradeNo());
+        fields.put("Action", action);
+        fields.put("TotalAmount", String.valueOf(attempt.getAmount()));
+        fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        return fields;
+    }
+
+    static String nextAction(String status) {
+        return switch (status) {
+            case "已授權" -> "N";
+            case "要關帳" -> "E";
+            case "已關帳" -> "R";
+            default -> null;
+        };
+    }
+
+    private boolean inAutoCloseWindow() {
+        LocalTime now = ZonedDateTime.now(clock.withZone(ZoneId.of("Asia/Taipei"))).toLocalTime();
+        return !now.isBefore(LocalTime.of(20, 15)) && now.isBefore(LocalTime.of(20, 30));
+    }
+
+    private void markRefunded(Long refundId, OmgPaymentAttempt attempt) {
+        jdbc.update("UPDATE pos_order_omg_refund SET status='REFUNDED', last_error=NULL, "
+                + "refund_time=NOW(), update_time=NOW() WHERE id=?", refundId);
+        jdbc.update("UPDATE pos_order SET pay_status=2 WHERE dd_id=? AND state=4 "
+                        + "AND pay_status=1 AND pay_type IN ('2','5') "
+                        + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
+                        + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
+                        + "WHERE a.dd_id=pos_order.dd_id AND a.attempt_status=1 "
+                        + "AND (r.id IS NULL OR r.status!='REFUNDED'))", attempt.getDdId());
+    }
+
+    private void defer(Long refundId, String reason, String status) {
+        jdbc.update("UPDATE pos_order_omg_refund SET status=?, last_error=?, update_time=NOW() WHERE id=?",
+                status, reason, refundId);
+    }
+
+    private record RefundRow(Long attemptId, String status, String lastAction, String lastGatewayStatus) { }
 }

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.omgpay;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/** 由若依 sys_job 调度;每条意图有数据库租约,重复执行不会并发发送动作。 */
+@Component("omgPaymentRefundTask")
+public class OmgPaymentRefundTask {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundTask.class);
+    private final OmgPaymentRefundService refunds;
+
+    public OmgPaymentRefundTask(OmgPaymentRefundService refunds) {
+        this.refunds = refunds;
+    }
+
+    public void reconcilePending() {
+        refunds.ensureOrphanedCapturedIntents();
+        for (Long id : refunds.pendingIds(100)) {
+            try {
+                refunds.processOne(id);
+            } catch (RuntimeException error) {
+                log.error("OMG refund reconciliation failed id={}", id, error);
+            }
+        }
+        refunds.reconcileRefundedOrderStatuses();
+    }
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgRefundGateway.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.omgpay;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** OMG 正式信用卡查询/退款 HTTP 边界;调用前由服务确认环境。 */
+@Component
+public class OmgRefundGateway {
+    private static final String QUERY_URL = "https://payment.funpoint.com.tw/CreditDetail/QueryTrade/V2";
+    private static final String ACTION_URL = "https://payment.funpoint.com.tw/CreditDetail/DoAction";
+    private static final int MAX_RESPONSE_BYTES = 32 * 1024;
+    private static final RequestConfig CONFIG = RequestConfig.custom()
+            .setConnectTimeout(5_000).setConnectionRequestTimeout(5_000)
+            .setSocketTimeout(10_000).build();
+
+    public String queryCredit(Map<String, String> fields) {
+        return post(QUERY_URL, fields);
+    }
+
+    public String doAction(Map<String, String> fields) {
+        return post(ACTION_URL, fields);
+    }
+
+    private String post(String url, Map<String, String> fields) {
+        List<NameValuePair> form = new ArrayList<>();
+        fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
+        HttpPost post = new HttpPost(url);
+        post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.custom()
+                .setDefaultRequestConfig(CONFIG).disableRedirectHandling().build();
+             CloseableHttpResponse response = client.execute(post)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300 || response.getEntity() == null
+                    || response.getEntity().getContentLength() > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG refund gateway returned invalid HTTP response");
+            }
+            byte[] bytes = EntityUtils.toByteArray(response.getEntity());
+            if (bytes.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG refund gateway response is oversized");
+            }
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (IOException error) {
+            throw new IllegalStateException("OMG refund gateway unavailable", error);
+        }
+    }
+}

+ 93 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java

@@ -1,6 +1,11 @@
 package com.ruoyi.app.order;
 
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.push.BusinessPushProperties;
+import com.ruoyi.app.push.NativeRiderBatchPushService;
+import com.ruoyi.app.push.UserNotificationScene;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.framework.manager.AsyncManager;
@@ -22,12 +27,15 @@ import java.util.TimerTask;
 /** 外送订单开放接单及取消后的推送。 */
 @Service
 public class DeliveryOrderNotificationService {
+    private static final Logger log = LoggerFactory.getLogger(DeliveryOrderNotificationService.class);
     private static final int RIDER_NOTIFICATION_LIMIT = 20;
 
     private final RiderPositionMapper riderPositionMapper;
     private final PushEventService pushEventService;
     private final IInfoUserService infoUserService;
     private final MerchantNotificationRouter merchantNotificationRouter;
+    private final BusinessPushProperties pushProperties;
+    private final NativeRiderBatchPushService nativeRiderBatch;
 
     public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
                                             PushEventService pushEventService) {
@@ -40,19 +48,37 @@ public class DeliveryOrderNotificationService {
         this(riderPositionMapper, pushEventService, infoUserService, null);
     }
 
-    @Autowired
     public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
                                             PushEventService pushEventService,
                                             IInfoUserService infoUserService,
                                             MerchantNotificationRouter merchantNotificationRouter) {
+        this(riderPositionMapper, pushEventService, infoUserService, merchantNotificationRouter, null, null);
+    }
+
+    @Autowired
+    public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
+                                            PushEventService pushEventService,
+                                            IInfoUserService infoUserService,
+                                            MerchantNotificationRouter merchantNotificationRouter,
+                                            BusinessPushProperties pushProperties,
+                                            NativeRiderBatchPushService nativeRiderBatch) {
         this.riderPositionMapper = riderPositionMapper;
         this.pushEventService = pushEventService;
         this.infoUserService = infoUserService;
         this.merchantNotificationRouter = merchantNotificationRouter;
+        this.pushProperties = pushProperties;
+        this.nativeRiderBatch = nativeRiderBatch;
     }
 
-    /** 支付完成且仍待骑手接单时,通知附近可接单骑手。 */
+    /**
+     * 支付完成后的新外送订单通知:自配送订单通知商家,其余订单通知附近可接单骑手。
+     * 所有下单、支付回调与线下转账确认入口复用这里,避免遗漏自配送分流。
+     */
     public void notifyOrderAvailable(PosOrder order) {
+        if (isAvailableForSelfDeliveryMerchant(order)) {
+            notifySelfDeliveryOrderToMerchant(order);
+            return;
+        }
         if (!isAvailableForRider(order)) {
             return;
         }
@@ -60,15 +86,32 @@ public class DeliveryOrderNotificationService {
             List<RiderPosition> riders = riderPositionMapper.getAcceptRiderList(
                     order.getLongitude(), order.getLatitude(), RIDER_NOTIFICATION_LIMIT);
             if (riders != null) {
-                riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                if (pushProperties != null && pushProperties.getMode() == BusinessPushProperties.Mode.NATIVE
+                        && nativeRiderBatch != null) {
+                    String ddId = String.valueOf(order.getDdId());
+                    String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
+                    nativeRiderBatch.sendNewOrder(riders, "no.message.push.message",
+                            "no.message.push.new.food.order", body, ddId,
+                            rider -> sendAvailableOrderToRider(rider, order));
+                } else {
+                    riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+                }
             }
         });
     }
 
-    /** 已分配骑手的外送订单取消后,通知除取消人外的相关方。 */
+    /** 外送订单取消后,通知除取消人外的相关方。 */
     public void notifyOrderCancelled(PosOrder order, Long operatorId) {
+        notifyOrderCancelled(order, operatorId, false);
+    }
+
+    public void notifyNoRiderCancelled(PosOrder order) {
+        notifyOrderCancelled(order, null, true);
+    }
+
+    private void notifyOrderCancelled(PosOrder order, Long operatorId, boolean noRiderTimeout) {
         if (order == null || !Long.valueOf(0L).equals(order.getType())
-                || order.getQsId() == null || infoUserService == null) {
+                || infoUserService == null) {
             return;
         }
         runAfterCommit(() -> {
@@ -85,7 +128,7 @@ public class DeliveryOrderNotificationService {
             }
             List<InfoUser> recipients = infoUserService.listByIds(recipientIds);
             if (recipients != null) {
-                recipients.forEach(recipient -> sendCancelledOrder(recipient, order));
+                recipients.forEach(recipient -> sendCancelledOrder(recipient, order, noRiderTimeout));
             }
             if (merchantNotificationRouter != null && shouldNotifyMerchant(operatorId)) {
                 String ddId = String.valueOf(order.getDdId());
@@ -120,6 +163,10 @@ public class DeliveryOrderNotificationService {
     }
 
     void sendCancelledOrder(InfoUser recipient, PosOrder order) {
+        sendCancelledOrder(recipient, order, false);
+    }
+
+    private void sendCancelledOrder(InfoUser recipient, PosOrder order, boolean noRiderTimeout) {
         if (recipient == null || recipient.getUserId() == null) {
             return;
         }
@@ -136,14 +183,43 @@ public class DeliveryOrderNotificationService {
                     PayPush.shPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
                             "no.message.push.message", "no.message.push.order.cancelled", body, "", ddId);
                 } else {
+                    // 只有原链路实际通知的用户带场景,取消操作者和非用户通知范围不变。
+                    String userBody = OrderPushBodyDto.getUserJson(ddId, "4", 0, 1,
+                            UserNotificationScene.ORDER_CANCELLED);
+                    String messageKey = noRiderTimeout && "3".equals(order.getPayType())
+                            ? "no.message.push.order.no.rider.line.cancelled"
+                            : "no.message.push.order.cancelled";
                     PayPush.userPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
-                            "no.message.push.message", "no.message.push.order.cancelled", body, "", ddId);
+                            "no.message.push.message", messageKey, userBody, "", ddId);
                 }
             }
         });
     }
 
+    /** 自配送新单只允许通知实际门店及其归属商家,绝不进入骑手通知链路。 */
+    private void notifySelfDeliveryOrderToMerchant(PosOrder order) {
+        if (merchantNotificationRouter == null) {
+            return;
+        }
+        runAfterCommit(() -> {
+            String ddId = String.valueOf(order.getDdId());
+            String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
+            merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
+                    "no.message.push.message", "no.message.push.new.order", body, ddId);
+        });
+    }
+
+    private boolean isAvailableForSelfDeliveryMerchant(PosOrder order) {
+        return isPaidUnassignedDeliveryOrder(order) && Integer.valueOf(1).equals(order.getSelfDelivery());
+    }
+
     private boolean isAvailableForRider(PosOrder order) {
+        return isPaidUnassignedDeliveryOrder(order)
+                && Boolean.TRUE.equals(order.getIsDisplay())
+                && !Integer.valueOf(1).equals(order.getSelfDelivery());
+    }
+
+    private boolean isPaidUnassignedDeliveryOrder(PosOrder order) {
         return order != null
                 && Long.valueOf(0L).equals(order.getType())
                 && Long.valueOf(0L).equals(order.getState())
@@ -154,14 +230,22 @@ public class DeliveryOrderNotificationService {
     }
 
     private static void runAfterCommit(Runnable action) {
+        // 已提交的取消/开放接单不能被通知准备或调度失败反向影响。
+        Runnable safeAction = () -> {
+            try {
+                action.run();
+            } catch (Exception exception) {
+                log.error("外送订单通知准备失败", exception);
+            }
+        };
         if (!TransactionSynchronizationManager.isSynchronizationActive()) {
-            action.run();
+            safeAction.run();
             return;
         }
         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
             public void afterCommit() {
-                action.run();
+                safeAction.run();
             }
         });
     }

+ 14 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantNotificationRouter.java

@@ -1,7 +1,8 @@
 package com.ruoyi.app.order;
 
 import com.ruoyi.app.user.MerchantTokenSessionService;
-import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.utils.LocaleUtils;
 import com.ruoyi.common.utils.MessageUtils;
@@ -33,17 +34,20 @@ public class MerchantNotificationRouter {
     private final IInfoUserService infoUserService;
     private final MerchantTokenSessionService tokenSessionService;
     private final PushEventService pushEventService;
+    private final BusinessPushService businessPushService;
 
     public MerchantNotificationRouter(MerchantSubaccountStoreMapper relationMapper,
                                       PosStoreMapper posStoreMapper,
                                       IInfoUserService infoUserService,
                                       MerchantTokenSessionService tokenSessionService,
-                                      PushEventService pushEventService) {
+                                      PushEventService pushEventService,
+                                      BusinessPushService businessPushService) {
         this.relationMapper = relationMapper;
         this.posStoreMapper = posStoreMapper;
         this.infoUserService = infoUserService;
         this.tokenSessionService = tokenSessionService;
         this.pushEventService = pushEventService;
+        this.businessPushService = businessPushService;
     }
 
     public void sendStoreNotification(Long storeId, Long ownerUserId,
@@ -72,9 +76,9 @@ public class MerchantNotificationRouter {
             }
         }
 
-        Set<String> pushedCids = new LinkedHashSet<>();
+        Set<String> pushedAddresses = new LinkedHashSet<>();
         for (InfoUser recipient : recipients.values()) {
-            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, pushedCids);
+            sendToRecipient(recipient, titleKey, contentKey, body, orderNo, pushedAddresses);
         }
     }
 
@@ -103,7 +107,7 @@ public class MerchantNotificationRouter {
                 && MerchantAccountConstants.STATUS_ENABLED.equals(candidate.getStatus())
                 && MerchantAccountConstants.STATUS_ENABLED.equals(candidate.getSubaccountStatus())
                 && MerchantAccountConstants.NOT_DELETED.equals(candidate.getDelFlag())
-                && StringUtils.isNotEmpty(candidate.getCid())
+                && businessPushService.addressKey(candidate) != null
                 && tokenSessionService.hasAppSession(candidate.getUserId());
     }
 
@@ -116,7 +120,7 @@ public class MerchantNotificationRouter {
     }
 
     private void sendToRecipient(InfoUser recipient, String titleKey, String contentKey,
-                                 String body, String orderNo, Set<String> pushedCids) {
+                                 String body, String orderNo, Set<String> pushedAddresses) {
         String title = localize(titleKey, recipient.getUserId());
         String content = localize(contentKey, recipient.getUserId());
         if (StringUtils.isNotEmpty(orderNo)) {
@@ -128,12 +132,13 @@ public class MerchantNotificationRouter {
             log.error("Store notification message publish failed, userId={}", recipient.getUserId(), exception);
         }
 
-        String cid = recipient.getCid();
-        if (StringUtils.isEmpty(cid) || !pushedCids.add(cid)) {
+        String address = businessPushService.addressKey(recipient);
+        if (address == null || !pushedAddresses.add(address)) {
             return;
         }
         try {
-            sendExternal(cid, title, content, body);
+            businessPushService.send(RecipientApp.MERCHANT, recipient.getUserId(), recipient.getCid(),
+                    title, content, body);
         } catch (Exception exception) {
             log.error("Store notification external push failed, userId={}", recipient.getUserId(), exception);
         }
@@ -148,10 +153,6 @@ public class MerchantNotificationRouter {
         pushEventService.PublisherEvent(userId, title, content, body);
     }
 
-    void sendExternal(String cid, String title, String content, String body) {
-        new PayPush().shpush(cid, title, content, body);
-    }
-
     private record StoreRoute(Long ownerUserId, boolean storeValid) {
     }
 }

+ 3 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderAppealController.java

@@ -393,14 +393,14 @@ public class OrderAppealController extends BaseController {
 
     /**
      * 解析语言字符串为 Locale 对象
-     * 支持格式:zh_CN, zh-CN, zh, en-US, en_US, vi 等
+     * 支持格式:zh_CN, zh-CN, zh, en-US, en_US 等
      *
      * @param language 语言字符串
      * @return Locale 对象
      */
     private Locale parseLocale(String language) {
         if (language == null || language.isEmpty()) {
-            return new Locale("vi"); // 默认越南语
+            return new Locale("zh", "TW"); // 默认台湾繁体(越南语已于2026-09-11移除)
         }
 
         // 处理下划线格式(如 zh_CN)
@@ -418,7 +418,7 @@ public class OrderAppealController extends BaseController {
             return Locale.forLanguageTag(language);
         }
 
-        // 简单语言代码(如 zh, en, vi)
+        // 简单语言代码(如 zh, en, th)
         return new Locale(language);
     }
 

+ 25 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -52,7 +52,7 @@ public class OrderLifecycleService {
     private final IPosOrderLinePaymentService linePaymentService;
     private final IPosOrderLineRefundService lineRefundService;
     private final RiderDeliveryLockService riderDeliveryLockService;
-    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
 
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
@@ -83,7 +83,7 @@ public class OrderLifecycleService {
                                  IPosOrderLinePaymentService linePaymentService,
                                  IPosOrderLineRefundService lineRefundService,
                                  RiderDeliveryLockService riderDeliveryLockService,
-                                 RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                 RiderDeliveryAcceptanceService riderDeliveryAcceptanceService) {
         this.posOrderService = posOrderService;
         this.billingService = billingService;
         this.orderLogHelper = orderLogHelper;
@@ -92,7 +92,7 @@ public class OrderLifecycleService {
         this.linePaymentService = linePaymentService;
         this.lineRefundService = lineRefundService;
         this.riderDeliveryLockService = riderDeliveryLockService;
-        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
@@ -159,7 +159,8 @@ public class OrderLifecycleService {
     @Transactional(rollbackFor = Exception.class)
     public void acceptDeliveryByRider(Long id, Long riderId) {
         riderDeliveryLockService.withLock(riderId, () -> {
-            riderDeliveryExclusivityService.assertCanAcceptFood(riderId);
+            // 资格、外卖/闪送容量与急送独占必须和订单更新处于同一骑手锁内,避免并发接单绕过上限。
+            riderDeliveryAcceptanceService.assertCanAcceptFood(riderId);
             acceptDeliveryByRiderLocked(id, riderId);
             return null;
         });
@@ -170,7 +171,9 @@ public class OrderLifecycleService {
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())
                 || !Long.valueOf(1L).equals(order.getPayStatus())
-                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null) {
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null
+                || !Boolean.TRUE.equals(order.getIsDisplay())
+                || Integer.valueOf(1).equals(order.getSelfDelivery())) {
             throw conflict();
         }
         PosOrder update = new PosOrder();
@@ -283,8 +286,14 @@ public class OrderLifecycleService {
 
         PosOrder update = new PosOrder();
         update.setPayStatus(1L);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
         applyCas(order, update);
         order.setPayStatus(1L);
+        if (update.getEffectiveAt() != null) {
+            order.setEffectiveAt(update.getEffectiveAt());
+        }
         logStatusChange(order, request, operatorId, operatorName,
                 request.getExpectedState(), request.getExpectedDeliveryStatus(),
                 request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
@@ -496,6 +505,12 @@ public class OrderLifecycleService {
                 wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
             }
         }
+        if (update.getQsId() != null) {
+            wrapper.eq(PosOrder::getIsDisplay, true)
+                    .isNull(PosOrder::getQsId)
+                    .and(w -> w.isNull(PosOrder::getSelfDelivery)
+                            .or().eq(PosOrder::getSelfDelivery, 0));
+        }
         if (!posOrderService.update(update, wrapper)) {
             throw conflict();
         }
@@ -518,6 +533,11 @@ public class OrderLifecycleService {
         if (order.getType() == 0L && order.getQsId() != null) {
             billingService.setQishouBilling(order);
         }
+        // 029 商家自配送:无骑手(qsId 为空)的外送单由配送方(sh_id)得全额运费
+        if (order.getType() == 0L && order.getQsId() == null
+                && Integer.valueOf(1).equals(order.getSelfDelivery())) {
+            billingService.setSelfDeliveryFreightBilling(order);
+        }
     }
 
     private void refundPoints(PosOrder order) {

+ 31 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java

@@ -157,4 +157,35 @@ public class OrderService {
             }
         }
     }
+
+    /**
+     * 执行自配送运费入账(029):运费全额(平台不抽成,2026-09-08 确认)入账配送方。
+     * user_id=sh_id——普通商家单=商家本人,夜市摊位单=夜市主(摊位订单 shId 即夜市主,research F3),
+     * 夜市主与摊位主之间的运费分摊线下自理。type="5" 与商品分成(type=0)区分,dedup 键 (userId,type,ddId) 互不冲突;
+     * 运费为空或 0 不生成零元账单。
+     */
+    public void setSelfDeliveryFreightBilling(PosOrder posOrder) {
+        if (posOrder.getFreight() == null || posOrder.getFreight() <= 0) {
+            return;
+        }
+        long count = userBillingService.count(new LambdaQueryWrapper<UserBilling>()
+                .eq(UserBilling::getUserId, posOrder.getShId())
+                .eq(UserBilling::getType, "5")
+                .eq(UserBilling::getDdId, posOrder.getDdId()));
+        if (count <= 0) {
+            UserBilling billing = new UserBilling();
+            DecimalFormat df = new DecimalFormat("#.00");
+            billing.setUserId(posOrder.getShId());
+            billing.setDdId(String.valueOf(posOrder.getDdId()));
+            billing.setType("5");
+            billing.setAmount(Double.valueOf(df.format(posOrder.getFreight())));
+            billing.setDivvy(0.00);
+            billing.setDivvyRate(0.00);
+            billing.setState("0");
+            billing.setMdId(posOrder.getMdId());
+            billing.setUserType("1");
+            billing.setIllustrate(MessageUtils.message("no.billing.selfdelivery.freight"));
+            walletService.addBalance(billing.getUserId(), BigDecimal.valueOf(billing.getAmount()), billing);
+        }
+    }
 }

+ 60 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -18,6 +18,7 @@ import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusContext;
 import com.ruoyi.app.pay.LinePayService;
 import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.service.WalletService;
@@ -53,6 +54,7 @@ import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.GetArea;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.OrderLogHelper;
+import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.common.core.domain.entity.SysUser;
 import lombok.SneakyThrows;
@@ -79,7 +81,16 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 /**
- * posorderController
+ * 外卖订单通用管理控制器(查询与平台管理向)。
+ *
+ * <p>用户、骑手、商家的订单操作入口不在此类(2026-09-29 记录):
+ * 用户操作(下单/取消)= UserOrderController,骑手操作(接单/取餐/送达)= PosOrderQsOprateController,
+ * 商家履约操作(出餐/商家取消)= PosOrderShOprateController;状态流转类操作禁止新增到本类。
+ *
+ * <p>本类仍在用的:列表/详情查询(/getorderlist、/getstoreorderlist、/getqsorderlist、/getorderxq 等)、
+ * 平台管理操作(/{id}/status、/{id}/line-refund、/{id}/offline-payment/confirm、/{id}/offline-refund/confirm 等
+ * Spring Security 权限接口)、平台改状态 /setorderuzt;
+ * add/edit 及 /addorder 均已废弃(商家端下单走 PosOrderShOprateController /createOrder),仅保留备查,勿增量修改。
  *
  * @author ruoyi
  * @date 2023-05-23
@@ -94,6 +105,11 @@ public class PosOrderController extends BaseController {
     @Autowired
     private IPosStoreService posStoreService;
     @Autowired
+    private StallQualificationGateService stallQualificationGateService;
+    /** 支付方式闸门(031):商家建单与用户下单共用同一判定入口。 */
+    @Autowired
+    private com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService;
+    @Autowired
     private IInfoAddressService infoAddressService;
     @Autowired
     private IUserFootprintService userFootprintService;
@@ -127,6 +143,8 @@ public class PosOrderController extends BaseController {
     @Autowired
     private PushEventService pushEventService;
     @Autowired
+    private BusinessPushService businessPushService;
+    @Autowired
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
     @Autowired
     private MerchantNotificationRouter merchantNotificationRouter;
@@ -265,7 +283,7 @@ public class PosOrderController extends BaseController {
                 System.out.println("进入设置paytype等于1");
                 posOrder.setPayType("1");
             }
-            //取餐时设置预计送达时间
+            //取餐时设置预计送达时间(已停用 2026-09-29:setYjsdTime 不再使用,保留调用不影响状态流转)
             setYjsdTime(posOrder);
             if (posOrder.getState() == 5) {
                 return setOrderState5(posOrder);
@@ -362,7 +380,9 @@ public class PosOrderController extends BaseController {
                                     lock.unlock();
                                 }
                                 // 给用户推送(骑手已接单/配送中/已送达)
-                                if (!StringUtils.isEmpty(user.getCid())) {
+                                try {
+                                // 无设备地址仍生成用户消息;商家/骑手保持原协议。
+                                if (user != null) {
                                     String userTitle = "no.message.push.message";
                                     String userContent = "no.message.push.delivery.personnel.receiving.order";
                                     if (posOrder.getState() == 4) {
@@ -371,7 +391,12 @@ public class PosOrderController extends BaseController {
                                         userContent = "no.message.push.delivery.personnel.qsysd.order";
                                     }
                                     final String finalUserContent = userContent;
-                                    String userBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(posOrder.getState()));
+                                    UserNotificationScene scene = Long.valueOf(4L).equals(posOrder.getState())
+                                            ? UserNotificationScene.ORDER_DELIVERING
+                                            : Long.valueOf(12L).equals(posOrder.getState())
+                                            ? UserNotificationScene.ORDER_DELIVERED : UserNotificationScene.RIDER_ACCEPTED;
+                                    String userBody = OrderPushBodyDto.getUserJson(String.valueOf(orst.getDdId()),
+                                            String.valueOf(posOrder.getState()), -1, 1, scene);
                                     Long userUserId = user.getUserId();
                                     String userCid = user.getCid();
                                     String userCidType = "";
@@ -397,6 +422,10 @@ public class PosOrderController extends BaseController {
                                         }
                                     });
                                 }
+                                } catch (Exception exception) {
+                                    // 仅隔离已提交业务的通知投递异常,不改变旧状态流转。
+                                    logger.error("骑手履约通知投递失败,ddId={}", orst.getDdId(), exception);
+                                }
                             }
                         });
                         return success(MessageUtils.message("no.modify.success"), posOrder.getId());
@@ -597,6 +626,9 @@ public class PosOrderController extends BaseController {
      * @param posOrder
      * @return
      */
+    //已停用(2026-09-29 标记):本方法已不再使用,保留代码备查;
+    //内部依赖已停用(2026-09-29 标记)的 Mapbox 耗时接口
+    @Deprecated
     private String setYjsdTime(PosOrder posOrder) {
         try {
             if (posOrder.getState() == 4) {
@@ -606,6 +638,7 @@ public class PosOrderController extends BaseController {
                 InfoAddress shDz = infoAddressService.getById(order.getShdzId());
 
                 String shlat = shDz.getLongitude() + "," + shDz.getLatitude();
+                //依赖已停用(2026-09-29 标记)的 Mapbox 耗时接口;存量逻辑暂保留,待切自部署路线源时一并更换
                 double time = getArea.getMotorbikeDuration(mdlat, shlat);
                 DateUtil dateUtil = new DateUtil();
                 String start = dateUtil.getHourAndMinute((long) time);
@@ -622,6 +655,7 @@ public class PosOrderController extends BaseController {
     }
 
     //修改订单
+    //已停用(2026-09-29 标记):测试端点,随 setYjsdTime 一并停用,保留备查
     @Anonymous
     @PostMapping("/testYjsdTime")
     public String testYjsdTime(@RequestBody PosOrder order) {
@@ -1030,7 +1064,9 @@ public class PosOrderController extends BaseController {
 //        Boolean sbsj = dateUtil.isLegalTime(aa,bb);
 //        return success(sbsj);
 //    }
-    //添加新订单
+    //已废弃(2026-09-29 标注):商家端下单走 PosOrderShOprateController /createOrder;
+    //本入口为旧代客外送下单流程(货到付款),保留备查勿增量修改(内含 031 支付闸门调用点,随入口一并废弃)
+    @Deprecated
     @SneakyThrows
     @Anonymous
     @Auth
@@ -1055,6 +1091,8 @@ public class PosOrderController extends BaseController {
         wrapper.eq("md_id", orderDTO.getMdId());
         List<OperatingHours> hourslist = operatingHoursService.list(wrapper);
         PosStore store = posStoreService.getById(orderDTO.getMdId());
+        // 旧路由仍可调用,最小门禁防止从这里绕过摊位材料审核。
+        stallQualificationGateService.requireApproved(store);
 
         JSONObject usdizhi = new JSONObject();
         if (orderDTO.getType() != null && orderDTO.getType().equals(0L)) {
@@ -1223,6 +1261,13 @@ public class PosOrderController extends BaseController {
         }
         //设置支付类型为货到付款
         if ("1".equals(posOrder.getCollectPayment())) {
+            // 031 闸门对称校验:平台关闭到付时商家建单同样被拒,与用户下单同口径(spec FR-004)。
+            com.ruoyi.system.domain.PosStore payStore = posStoreService.getById(posOrder.getMdId());
+            if (payStore != null) {
+                paymentMethodGateService.assertUsable("1",
+                        com.ruoyi.system.service.PaymentMethodGateService.GateScope.MERCHANT,
+                        payStore.getId().longValue(), payStore.getUserId());
+            }
             posOrder.setPayType("1");
         }
         Boolean org = posOrderService.saveOrUpdate(posOrder);
@@ -1644,7 +1689,12 @@ public class PosOrderController extends BaseController {
 
     /**
      * 新增posorder
+     *
+     * @deprecated 已废弃(2026-09-29 标注,勿增量修改):用户下单走 UserOrderController.createOrder,
+     * 商家端下单走 PosOrderShOprateController.createOrder(/system/orderShOprate/createOrder);
+     * 本 RuoYi CRUD 入口仅保留备查。
      */
+    @Deprecated
     @PreAuthorize("@ss.hasPermi('system:order:add')")
     @Log(title = "posorder", businessType = BusinessType.INSERT)
     @PostMapping
@@ -1654,7 +1704,12 @@ public class PosOrderController extends BaseController {
 
     /**
      * 修改posorder
+     *
+     * @deprecated 已废弃(2026-09-29 标注,勿增量修改):订单状态流转已迁出到三个操作控制器
+     * (UserOrderController / PosOrderQsOprateController / PosOrderShOprateController),
+     * 修改订单信息的 RuoYi CRUD 入口仅保留备查。
      */
+    @Deprecated
     @Transactional(rollbackFor = Exception.class)
     @PreAuthorize("@ss.hasPermi('system:order:edit')")
     @Log(title = "posorder", businessType = BusinessType.UPDATE)

+ 212 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutService.java

@@ -0,0 +1,212 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.ruoyi.app.omgpay.OmgPaymentRefundService;
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.utils.DateUtil;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+/** 独立外送单的预约开放和无人接单收口。每单取消在独立事务中完成。 */
+@Service
+public class PosOrderNoRiderTimeoutService {
+    private final IPosOrderService orders;
+    private final IPosOrderLinePaymentService linePayments;
+    private final LinePayRefundService lineRefunds;
+    private final OmgPaymentRefundService omgRefunds;
+    private final OrderLogHelper orderLog;
+    private final DeliveryOrderNotificationService notifications;
+    private final JdbcTemplate jdbc;
+
+    public PosOrderNoRiderTimeoutService(IPosOrderService orders,
+                                        IPosOrderLinePaymentService linePayments,
+                                        LinePayRefundService lineRefunds,
+                                        OmgPaymentRefundService omgRefunds,
+                                        OrderLogHelper orderLog,
+                                        DeliveryOrderNotificationService notifications,
+                                        JdbcTemplate jdbc) {
+        this.orders = orders;
+        this.linePayments = linePayments;
+        this.lineRefunds = lineRefunds;
+        this.omgRefunds = omgRefunds;
+        this.orderLog = orderLog;
+        this.notifications = notifications;
+        this.jdbc = jdbc;
+    }
+
+    public int timeoutMinutes() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("pos_order_auto_cancel");
+            if (values != null && !values.isEmpty()) {
+                int minutes = Integer.parseInt(values.get(0).getDictValue());
+                if (minutes >= 1 && minutes <= 1440) {
+                    return minutes;
+                }
+            }
+        } catch (RuntimeException ignored) {
+            // 字典不可用时仍按已确认的 30 分钟口径处理。
+        }
+        return 30;
+    }
+
+    public List<PosOrder> scheduledToOpen(Date now, int limit) {
+        Date openBefore = Date.from(now.toInstant().plusSeconds((long) DateUtil.getAppoinmentOrderHandleTime() * 60));
+        return orders.list(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getType, 0L).eq(PosOrder::getState, 0L)
+                .eq(PosOrder::getPayStatus, 1L).eq(PosOrder::getDeliveryStatus, 0L)
+                .eq(PosOrder::getAfterSaleStatus, 0L).isNotNull(PosOrder::getEffectiveAt)
+                .eq(PosOrder::getIsDisplay, false).isNull(PosOrder::getQsId)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .isNotNull(PosOrder::getDelryTime).ne(PosOrder::getDelryTime, "")
+                .apply("STR_TO_DATE(LEFT(delry_time, 16), '%Y-%m-%d %H:%i') <= {0}", openBefore)
+                .orderByAsc(PosOrder::getId).last("LIMIT " + limit));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean openScheduled(Long id, Date now) {
+        PosOrder order = orders.getById(id);
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || !Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || order.getEffectiveAt() == null
+                || Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                || Integer.valueOf(1).equals(order.getSelfDelivery())
+                || !order.getDdId().equals(order.getParentDdId())
+                || order.getDelryTime() == null || order.getDelryTime().isBlank()) {
+            return false;
+        }
+        Date start = appointmentStart(order.getDelryTime());
+        if (start == null || start.toInstant().isAfter(now.toInstant()
+                .plusSeconds((long) DateUtil.getAppoinmentOrderHandleTime() * 60))) {
+            return false;
+        }
+        boolean opened = orders.update(new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).eq(PosOrder::getType, 0L)
+                .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
+                .eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getAfterSaleStatus, 0L)
+                .isNotNull(PosOrder::getEffectiveAt)
+                .eq(PosOrder::getIsDisplay, false).isNull(PosOrder::getQsId)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .eq(PosOrder::getDelryTime, order.getDelryTime())
+                .set(PosOrder::getIsDisplay, true).set(PosOrder::getDisplayTime, now));
+        if (opened) {
+            order.setIsDisplay(true);
+            order.setDisplayTime(now);
+            notifications.notifyOrderAvailable(order);
+        }
+        return opened;
+    }
+
+    public List<PosOrder> expiredCandidates(Date now, int minutes, int limit) {
+        Date cutoff = Date.from(now.toInstant().minusSeconds((long) minutes * 60));
+        return orders.list(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getType, 0L).eq(PosOrder::getState, 0L)
+                .eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getPayStatus, 1L)
+                .eq(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getIsDisplay, true)
+                .isNull(PosOrder::getQsId).isNotNull(PosOrder::getEffectiveAt)
+                .le(PosOrder::getEffectiveAt, cutoff).le(PosOrder::getDisplayTime, cutoff)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .and(w -> w.isNull(PosOrder::getDelryTime).or().eq(PosOrder::getDelryTime, "")
+                        .or().apply("STR_TO_DATE(LEFT(delry_time, 16), '%Y-%m-%d %H:%i') <= {0}", cutoff))
+                .orderByAsc(PosOrder::getEffectiveAt).last("LIMIT " + limit));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelExpired(Long id, Date now, int minutes) {
+        PosOrder order = orders.getById(id);
+        if (!eligible(order, now, minutes)) {
+            return false;
+        }
+        Date cutoff = Date.from(now.toInstant().minusSeconds((long) minutes * 60));
+        LambdaUpdateWrapper<PosOrder> guard = new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).eq(PosOrder::getType, 0L)
+                .eq(PosOrder::getState, 0L).eq(PosOrder::getDeliveryStatus, 0L)
+                .eq(PosOrder::getPayStatus, 1L).eq(PosOrder::getAfterSaleStatus, 0L)
+                .eq(PosOrder::getIsDisplay, true).isNull(PosOrder::getQsId)
+                .le(PosOrder::getEffectiveAt, cutoff).le(PosOrder::getDisplayTime, cutoff)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .set(PosOrder::getState, 4L);
+        if (order.getDelryTime() == null) {
+            guard.isNull(PosOrder::getDelryTime);
+        } else {
+            guard.eq(PosOrder::getDelryTime, order.getDelryTime());
+        }
+        boolean cancelled = orders.update(guard);
+        if (!cancelled) {
+            return false;
+        }
+        order.setState(4L);
+        if ("3".equals(order.getPayType())) {
+            for (PosOrderLinePayment payment : linePayments.getByDdId(order.getDdId())) {
+                if ("PAID".equals(payment.getStatus())) {
+                    lineRefunds.createRefundIntent(payment.getId(), "AUTO_NO_RIDER");
+                }
+            }
+        } else if (OrderLifecycleService.isCardWalletPayType(order.getPayType())) {
+            omgRefunds.createRefundIntent(order.getDdId(), "AUTO_NO_RIDER");
+        } else if (OrderLifecycleService.PAY_TYPE_TRANSFER.equals(order.getPayType())) {
+            jdbc.update("INSERT IGNORE INTO pos_order_manual_refund (dd_id, reason, status, create_time) VALUES (?, ?, 'PENDING', NOW())",
+                    order.getDdId(), "AUTO_NO_RIDER");
+        }
+        orderLog.logSync(order.getDdId(), 0, null, "SYSTEM", "超时无骑手自动取消");
+        notifications.notifyNoRiderCancelled(order);
+        return true;
+    }
+
+    static boolean eligible(PosOrder order, Date now, int minutes) {
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || !Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || !Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                || Integer.valueOf(1).equals(order.getSelfDelivery())
+                || order.getEffectiveAt() == null || order.getDisplayTime() == null
+                || !order.getDdId().equals(order.getParentDdId())) {
+            return false;
+        }
+        Date start = appointmentStart(order.getDelryTime());
+        if (order.getDelryTime() != null && !order.getDelryTime().isBlank() && start == null) {
+            return false;
+        }
+        long baseline = Math.max(order.getEffectiveAt().getTime(), order.getDisplayTime().getTime());
+        if (start != null) {
+            baseline = Math.max(baseline, start.getTime());
+        }
+        return baseline <= now.getTime() - (long) minutes * 60_000;
+    }
+
+    static Date appointmentStart(String window) {
+        if (window == null || window.isBlank()) {
+            return null;
+        }
+        try {
+            String[] parts = window.trim().split(" ");
+            String start = parts[1].split("-")[0];
+            return Date.from(LocalDateTime.parse(parts[0] + "T" + start)
+                    .atZone(ZoneId.of("Asia/Taipei")).toInstant());
+        } catch (RuntimeException invalid) {
+            return null;
+        }
+    }
+}

+ 42 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutTask.java

@@ -0,0 +1,42 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+/** 由若依 sys_job 每分钟调度,事务由逐单服务方法提供。 */
+@Component("posOrderNoRiderTimeoutTask")
+public class PosOrderNoRiderTimeoutTask {
+    private static final Logger log = LoggerFactory.getLogger(PosOrderNoRiderTimeoutTask.class);
+    private final PosOrderNoRiderTimeoutService service;
+
+    public PosOrderNoRiderTimeoutTask(PosOrderNoRiderTimeoutService service) {
+        this.service = service;
+    }
+
+    public void openScheduledOrders() {
+        Date now = new Date();
+        for (PosOrder order : service.scheduledToOpen(now, 200)) {
+            try {
+                service.openScheduled(order.getId(), new Date());
+            } catch (RuntimeException error) {
+                log.error("Open scheduled food order failed, id={}", order.getId(), error);
+            }
+        }
+    }
+
+    public void cancelExpiredOrders() {
+        Date now = new Date();
+        int minutes = service.timeoutMinutes();
+        for (PosOrder order : service.expiredCandidates(now, minutes, 200)) {
+            try {
+                service.cancelExpired(order.getId(), new Date(), minutes);
+            } catch (RuntimeException error) {
+                log.error("Cancel no-rider food order failed, id={}", order.getId(), error);
+            }
+        }
+    }
+}

部分文件因为文件数量过多而无法显示