Przeglądaj źródła

Merge branch 'test'

qmj 4 dni temu
rodzic
commit
068dde3994
100 zmienionych plików z 8047 dodań i 97 usunięć
  1. 18 0
      .claude/homunculus/instincts/personal/avoid-javadoc-terminator.md
  2. 20 0
      .claude/homunculus/instincts/personal/batch-verification-after-all-fixes.md
  3. 20 0
      .claude/homunculus/instincts/personal/build-with-jdk21.md
  4. 23 0
      .claude/homunculus/instincts/personal/controller-typed-request-contracts.md
  5. 19 0
      .claude/homunculus/instincts/personal/ezpay-love-code-constraints.md
  6. 19 0
      .claude/homunculus/instincts/personal/food-spec-price-model.md
  7. 20 0
      .claude/homunculus/instincts/personal/invoice-member-paper-mode.md
  8. 19 0
      .claude/homunculus/instincts/personal/invoice-query-compliance-boundary.md
  9. 20 0
      .claude/homunculus/instincts/personal/keep-omg-independent.md
  10. 20 0
      .claude/homunculus/instincts/personal/line-redirect-uri-consistency.md
  11. 18 0
      .claude/homunculus/instincts/personal/preserve-project-crlf.md
  12. 19 0
      .claude/homunculus/instincts/personal/respect-module-boundaries.md
  13. 26 0
      .claude/homunculus/instincts/personal/resume-omg-attempt-lifecycle-review.md
  14. 19 0
      .claude/homunculus/instincts/personal/table-qrcode-normal-merchants.md
  15. 19 0
      .claude/homunculus/instincts/personal/use-speckit-workflow.md
  16. 20 0
      .claude/homunculus/instincts/personal/validate-requests-with-i18n.md
  17. 31 0
      .claude/homunculus/instincts/personal/verify-confirmed-decisions-with-subagents.md
  18. 0 0
      .claude/homunculus/observations.jsonl
  19. 1 0
      .gitignore
  20. 0 13
      .idea/modules.xml
  21. 1 1
      .specify/feature.json
  22. 0 1
      AGENTS.md
  23. 126 0
      AGENTS.md
  24. 36 4
      CLAUDE.md
  25. 103 0
      docs/app-install-attribution.md
  26. 1127 0
      docs/flash-delivery-app-api.md
  27. 501 0
      docs/merchant-subaccount-api.md
  28. 409 0
      docs/superpowers/plans/2026-09-07-backend-thai-i18n.md
  29. 109 0
      docs/superpowers/plans/2026-09-07-remove-flash-pricing-lock.md
  30. 72 0
      docs/superpowers/specs/2026-09-07-backend-thai-i18n-design.md
  31. 33 0
      docs/superpowers/specs/2026-09-07-remove-flash-pricing-lock-design.md
  32. 10 0
      ruoyi-admin/pom.xml
  33. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java
  34. 115 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java
  35. 77 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  36. 118 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  37. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java
  38. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressConfirmRequest.java
  39. 36 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java
  40. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressView.java
  41. 19 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java
  42. 47 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java
  43. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryDeliverRequest.java
  44. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  45. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  46. 78 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  47. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  48. 36 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  49. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  50. 67 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  51. 63 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  52. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  53. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  54. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderPageView.java
  55. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderSummaryView.java
  56. 34 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java
  57. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTipAddRequest.java
  58. 58 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  59. 44 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/InfoAddressRequest.java
  60. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java
  61. 48 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  62. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GeoPoint.java
  63. 92 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  64. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistance.java
  65. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  66. 1298 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  67. 76 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  68. 30 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryStateMachine.java
  69. 117 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookService.java
  70. 34 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java
  71. 51 15
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java
  72. 25 4
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java
  73. 49 27
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  74. 122 21
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  75. 139 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreLinePayController.java
  76. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreNewebpayController.java
  77. 16 11
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java
  78. 87 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java
  79. 19 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgMerchantTradeNoGenerator.java
  80. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyForm.java
  81. 63 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyFormParser.java
  82. 85 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilter.java
  83. 48 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRequestArgumentResolver.java
  84. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyWebMvcConfiguration.java
  85. 99 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentAutoCompensationTask.java
  86. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentBusinessException.java
  87. 80 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnService.java
  88. 211 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  89. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateOutcome.java
  90. 202 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  91. 34 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java
  92. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFactSource.java
  93. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentForm.java
  94. 75 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java
  95. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java
  96. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentMethod.java
  97. 343 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  98. 98 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java
  99. 56 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java
  100. 299 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

+ 18 - 0
.claude/homunculus/instincts/personal/avoid-javadoc-terminator.md

@@ -0,0 +1,18 @@
+---
+id: avoid-javadoc-terminator
+trigger: "when writing Java block comments or Javadoc containing wildcard-like values"
+confidence: 0.9
+domain: "code-style"
+source: "claude-code-memory-import"
+---
+
+# Do not terminate Java comments accidentally
+
+## Action
+
+- Never place an extra `*/` sequence inside Java block-comment or Javadoc content.
+- Describe values such as `ATM_*` or `CVS_*` as “ATM series” or “CVS series” instead of embedding a wildcard followed by a slash.
+
+## Evidence
+
+- Imported from Claude Code feedback memory `feedback-javadoc-comment-star-slash.md` on 2026-08-10 after repeated compile failures caused by premature comment termination.

+ 20 - 0
.claude/homunculus/instincts/personal/batch-verification-after-all-fixes.md

@@ -0,0 +1,20 @@
+---
+id: batch-verification-after-all-fixes
+trigger: "when the user is reviewing and approving multiple related problems one by one"
+confidence: 0.9
+domain: "workflow"
+source: "explicit-user-correction"
+---
+
+# Verify Only After the Whole Batch Is Finished
+
+## Action
+
+When several related issues are being confirmed and fixed one by one, only edit code, tests, and specifications during the individual issue steps. Do not run Maven, compilation, unit tests, or other build verification after each small fix. After the user confirms that all issues in the batch have been handled, run the agreed verification suite once, followed by the final diff check.
+
+If a static read-only inspection is needed while editing, it may be used, but it must not start a build or test process.
+
+## Evidence
+
+- On 2026-08-12, the user explicitly corrected repeated per-fix Maven runs and required: "完成所有后再测试".
+- A Maven retry was stopped immediately after the correction.

+ 20 - 0
.claude/homunculus/instincts/personal/build-with-jdk21.md

@@ -0,0 +1,20 @@
+---
+id: build-with-jdk21
+trigger: "when running Maven compilation or tests in foodie_server"
+confidence: 0.9
+domain: "workflow"
+source: "claude-code-memory-import"
+---
+
+# Build foodie_server with JDK 21
+
+## Action
+
+- Use JDK 21 because the Maven compiler is configured with `release 21`.
+- On the current machine, temporarily set `JAVA_HOME` to `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7` and prepend its `bin` directory to `PATH` for the command.
+- Do not change the user's global Java configuration.
+
+## Evidence
+
+- Imported from Claude Code project memory `project-build-env.md` on 2026-08-10.
+- The system default JDK is older and fails with `invalid flag: --release`.

+ 23 - 0
.claude/homunculus/instincts/personal/controller-typed-request-contracts.md

@@ -0,0 +1,23 @@
+---
+id: controller-typed-request-contracts
+trigger: "when creating or modifying any Spring Controller in foodie_server"
+confidence: 0.9
+domain: "code-style"
+source: "explicit-user-instruction"
+---
+
+# Use typed Controller request contracts
+
+## Action
+
+- Receive authentication tokens with the exact controller parameter form `@RequestHeader String token`.
+- Do not use `HttpServletRequest` to read tokens or inject it only for token access.
+- For POST handlers that receive business input, use an explicit DTO and annotate it with `@RequestBody`; do not rely on implicit binding.
+- For GET handlers, annotate every query parameter explicitly with `@RequestParam`. Continue to use `@RequestHeader` for tokens and `@PathVariable` for URL path variables.
+- Never use `Map<String, String>` or another Map as a handler-method input.
+- Bind third-party form callbacks to DTOs; convert a DTO to an internal Map only when a signature or gateway SDK requires it.
+
+## Evidence
+
+- The user explicitly established this as a permanent rule for all controllers on 2026-08-10.
+- `PosOrderController` demonstrates the required forms with `@RequestBody OrderDTO` on POST handlers and explicit `@RequestParam` annotations on GET query parameters.

+ 19 - 0
.claude/homunculus/instincts/personal/ezpay-love-code-constraints.md

@@ -0,0 +1,19 @@
+---
+id: ezpay-love-code-constraints
+trigger: "when implementing ezPay invoice donation codes"
+confidence: 0.9
+domain: "business-rule"
+source: "claude-code-memory-import"
+---
+
+# Enforce ezPay donation-code constraints
+
+## Action
+
+- Accept only 3–7 numeric digits for ezPay `LoveCode`; do not accept the Ministry platform's possible 8-digit codes because ezPay rejects them.
+- Remember that `checkLoveCode` returns existence only, not the organization name.
+- Source organization names from the Ministry's authoritative list and keep donation mutually exclusive with carriers.
+
+## Evidence
+
+- Imported from Claude Code reference memory `reference-lovecode-donation.md` on 2026-08-10.

+ 19 - 0
.claude/homunculus/instincts/personal/food-spec-price-model.md

@@ -0,0 +1,19 @@
+---
+id: food-spec-price-model
+trigger: "when modifying food specification groups, values, relations, or order pricing"
+confidence: 0.9
+domain: "business-rule"
+source: "claude-code-memory-import"
+---
+
+# Preserve the food specification price model
+
+## Action
+
+- Treat food specifications as specification groups and values with additive prices, not Cartesian-product SKUs.
+- Keep specification prices in the same `BigDecimal` unit as `pos_food.price`; do not copy a Long-in-cents model from another project.
+- Use `foodSpecsItems` as the detail field name and treat merchant-entered localized specification names as data keyed by `language`, not as i18n keys.
+
+## Evidence
+
+- Imported from Claude Code project memory `project-013-food-spec.md` on 2026-08-10.

+ 20 - 0
.claude/homunculus/instincts/personal/invoice-member-paper-mode.md

@@ -0,0 +1,20 @@
+---
+id: invoice-member-paper-mode
+trigger: "when modifying MEMBER invoice issuance or display logic"
+confidence: 0.9
+domain: "business-rule"
+source: "claude-code-memory-import"
+---
+
+# Issue MEMBER invoices in paper mode
+
+## Action
+
+- For `invoice_choice=MEMBER`, use B2C paper mode with `PrintFlag=Y`.
+- Do not send ezPay `CarrierType=2`, do not persist a carrier number for this mode, and do not require `BuyerEmail`.
+- Do not combine any carrier with `PrintFlag=Y`.
+
+## Evidence
+
+- Imported from Claude Code project memory `project-010-order-invoice.md` on 2026-08-10.
+- This corrects the earlier invalid `CarrierType=2` approach that caused ezPay `INV10013`.

+ 19 - 0
.claude/homunculus/instincts/personal/invoice-query-compliance-boundary.md

@@ -0,0 +1,19 @@
+---
+id: invoice-query-compliance-boundary
+trigger: "when proposing an in-app view of all invoices associated with a consumer carrier"
+confidence: 0.9
+domain: "business-rule"
+source: "claude-code-memory-import"
+---
+
+# Respect the Taiwan invoice-query compliance boundary
+
+## Action
+
+- Do not assume ezPay's issuance API can query a consumer's society-wide carrier invoices.
+- Treat Ministry of Finance consumer query APIs as a separate integration requiring ISO 27001, consumer carrier credentials, consent, audit retention, and renewed authorization.
+- Prefer showing invoices issued by this platform from `pos_order_invoice` and linking users to the official invoice service unless the compliance project is explicitly approved.
+
+## Evidence
+
+- Imported from Claude Code reference memory `reference-tw-einvoice-query-api.md` on 2026-08-10.

+ 20 - 0
.claude/homunculus/instincts/personal/keep-omg-independent.md

@@ -0,0 +1,20 @@
+---
+id: keep-omg-independent
+trigger: "when implementing or modifying OMG payment functionality"
+confidence: 0.9
+domain: "architecture"
+source: "claude-code-memory-import"
+---
+
+# Keep OMG payment independent from NewebPay
+
+## Action
+
+- Do not import or reuse NewebPay-specific code, encryption, credentials, or payment tables in OMG functionality.
+- Use OMG's independent `CheckMacValue` SHA-256 flow and OMG-specific credential, payment, and refund data.
+- Reuse only gateway-neutral platform infrastructure such as order lifecycle, push, and order logging.
+
+## Evidence
+
+- Imported from Claude Code project memory `project-016-omg-payment.md` on 2026-08-10.
+- Current API signatures and implementation status must be verified from the repository rather than copied from the dated memory.

+ 20 - 0
.claude/homunculus/instincts/personal/line-redirect-uri-consistency.md

@@ -0,0 +1,20 @@
+---
+id: line-redirect-uri-consistency
+trigger: "when changing LINE OAuth authorization or callback configuration"
+confidence: 0.9
+domain: "security"
+source: "claude-code-memory-import"
+---
+
+# Keep LINE OAuth redirect URIs identical
+
+## Action
+
+- Ensure the frontend authorization `redirect_uri`, backend token-exchange `redirect_uri`, and LINE Console callback allowlist entry are exactly identical.
+- Verify the active deployment environment before changing the URI; do not trust a dated memory value.
+- Preserve both supported flows: backend callback for system-browser/App-scheme login and WebView interception that sends the code to `/oauthLogin`.
+
+## Evidence
+
+- Imported from Claude Code project memory `project-017-oauth-login.md` on 2026-08-10.
+- URI drift previously caused `redirect_uri_mismatch` risk.

+ 18 - 0
.claude/homunculus/instincts/personal/preserve-project-crlf.md

@@ -0,0 +1,18 @@
+---
+id: preserve-project-crlf
+trigger: "when editing existing Java, XML, YAML, or frontend files in foodie projects"
+confidence: 0.9
+domain: "workflow"
+source: "claude-code-memory-import"
+---
+
+# Preserve existing CRLF line endings
+
+## Action
+
+- Preserve each file's existing line-ending style, especially CRLF in backend Java/XML/YAML and the two frontend projects.
+- Avoid whole-file rewrites or unrelated formatting for a small change.
+
+## Evidence
+
+- Imported from Claude Code memories `project-build-env.md` and `feedback-crlf-edit.md` on 2026-08-10.

+ 19 - 0
.claude/homunculus/instincts/personal/respect-module-boundaries.md

@@ -0,0 +1,19 @@
+---
+id: respect-module-boundaries
+trigger: "when placing backend services or external integration code in foodie_server"
+confidence: 0.9
+domain: "architecture"
+source: "claude-code-memory-import"
+---
+
+# Respect the Maven module dependency direction
+
+## Action
+
+- Keep the dependency direction `ruoyi-admin -> ruoyi-system`; never import `com.ruoyi.app.*` from `ruoyi-system`.
+- Place integrations that need external HTTP plus application services in `ruoyi-admin`.
+- Keep reusable entities, mappers, and database services in `ruoyi-system` when they do not require an admin-layer dependency.
+
+## Evidence
+
+- Imported from Claude Code project memory `project-build-env.md` on 2026-08-10.

+ 26 - 0
.claude/homunculus/instincts/personal/resume-omg-attempt-lifecycle-review.md

@@ -0,0 +1,26 @@
+---
+id: resume-omg-attempt-lifecycle-review
+trigger: "when resuming the OMG payment review after 2026-08-12"
+confidence: 0.9
+domain: "project-status"
+source: "explicit-user-request"
+---
+
+# Resume OMG Payment Attempt Lifecycle Review
+
+## Action
+
+Resume from `specs/016-omg-payment/tasks.md` T066 before continuing T065 or lower-priority OMG fixes. Reassess the payment-attempt lifecycle because the current three-minute reuse rule can create multiple simultaneously payable OMG transactions.
+
+The known open items are:
+
+- T066 P0: remove the unsupported three-minute payment-attempt expiry assumption; persist real deferred-payment `ExpireDate`; only rotate after a conclusive gateway failure or real expiry; never reuse one `MerchantTradeNo` with a newly generated request unless OMG explicitly guarantees that contract.
+- T067 P1: make query throttling fair and do not consume the ddId slot when no query is sent.
+- T065: after the attempt lifecycle is settled, complete durable refund recovery for payments that succeed after order cancellation.
+
+Do not run Maven or tests while these issues are being handled one by one. Run one unified verification only after the user confirms the batch is complete.
+
+## Evidence
+
+- User requested on 2026-08-12: "记录起来我们明天再继续".
+- Static review found `reuse-fresh-minutes=3` is not an OMG payment lifetime; official documentation applies three minutes to QueryTradeInfo `TimeStamp` validity.

+ 19 - 0
.claude/homunculus/instincts/personal/table-qrcode-normal-merchants.md

@@ -0,0 +1,19 @@
+---
+id: table-qrcode-normal-merchants
+trigger: "when designing or changing table QR-code permissions or queries"
+confidence: 0.9
+domain: "business-rule"
+source: "claude-code-memory-import"
+---
+
+# Support table QR codes for normal merchants
+
+## Action
+
+- Include normal merchants in table QR-code permissions and data queries.
+- Allow them to view QR codes for their own store and the orders associated with those codes.
+- Do not limit this feature to night-market stall owners or administrators.
+
+## Evidence
+
+- Imported from Claude Code feedback memory `feedback-merchant-table-qrcode.md` on 2026-08-10.

+ 19 - 0
.claude/homunculus/instincts/personal/use-speckit-workflow.md

@@ -0,0 +1,19 @@
+---
+id: use-speckit-workflow
+trigger: "when the user requests spec-kit planning or changes an existing specified feature"
+confidence: 0.9
+domain: "workflow"
+source: "claude-code-memory-import"
+---
+
+# Use the project's spec-kit workflow
+
+## Action
+
+- Interpret `spec kit`, `spec-kit`, and `speckit` as GitHub `github/spec-kit` configured in `.specify/` with documents in `specs/`.
+- For a new feature, follow `specify -> plan -> tasks -> implement`.
+- For an incremental requirement on an existing feature, update its existing `spec.md`, `plan.md`, and `tasks.md` instead of restarting the full workflow unless explicitly requested.
+
+## Evidence
+
+- Imported from global `~/.claude/CLAUDE.md` and project memories `reference-speckit.md`, `feedback-spec-workflow.md`, and `feedback_preferred_tools.md` on 2026-08-10.

+ 20 - 0
.claude/homunculus/instincts/personal/validate-requests-with-i18n.md

@@ -0,0 +1,20 @@
+---
+id: validate-requests-with-i18n
+trigger: "when defining or validating Controller request DTOs in foodie_server"
+confidence: 0.9
+domain: "code-style"
+source: "explicit-user-instruction"
+---
+
+# Validate request data with internationalized messages
+
+## Action
+
+- Keep request DTOs as data carriers; do not add Bean Validation annotations such as `@NotNull`, `@NotBlank`, or `@Size` to DTO fields.
+- Do not rely on `@Valid` or `@Validated` on Controller DTO parameters to produce validation responses.
+- Validate business request parameters in the Controller or Service layer.
+- Resolve every user-facing validation error through the project's internationalization mechanism, such as `MessageUtils.message(...)`; do not hardcode a single-language validation message.
+
+## Evidence
+
+- The user explicitly required DTO validation to be avoided because validation error messages must support internationalization on 2026-08-10.

+ 31 - 0
.claude/homunculus/instincts/personal/verify-confirmed-decisions-with-subagents.md

@@ -0,0 +1,31 @@
+---
+id: verify-confirmed-decisions-with-subagents
+trigger: "after implementing decisions that the user and Codex explicitly confirmed"
+confidence: 0.9
+domain: "workflow"
+source: "user-directive-2026-08-12"
+---
+
+# Verify Confirmed Decisions With Subagents
+
+## Action
+
+After implementing explicitly confirmed discussion decisions, assign subagents to check every confirmed item against the specification, code, tests, migrations, and user-facing behavior. Report each omission and resolve it before claiming completion.
+
+Do not ask the user to approve every verification item. Use the confirmed decisions as the review checklist.
+
+Use milestone-based batch reviews instead of fragmented "find one, fix one, review again" loops:
+
+1. **Design review before implementation**: give each subagent the same frozen, complete specification and a distinct review scope. Require each reviewer to finish its whole checklist and return all Critical/Important findings in one report, even after finding a blocker.
+2. **Implementation review after a complete milestone**: first compile and run the relevant tests, then freeze the workspace snapshot. Have subagents review the complete implementation and collect findings in one consolidated issue list. Batch-fix the list instead of interrupting implementation for every incoming finding.
+3. **Final confirmation before delivery**: after all fixes and verification pass, have each relevant subagent confirm its previously reported items and state whether the final snapshot has any remaining Critical/Important issues.
+
+For an ordinary localized fix, only the subagent that raised the issue performs a targeted confirmation. Repeat a full multi-subagent review only when a fix materially changes a payment/refund state machine, database uniqueness or transaction boundary, external financial side-effect ordering, order completion/cancellation/refund mutual exclusion, or when preparing final delivery.
+
+Subagents performing a review should be read-only and should not review a workspace that is still being edited. The main agent owns consolidation, implementation, and user communication.
+
+## Evidence
+
+- The user explicitly required this workflow for future confirmed discussions on 2026-08-12.
+- The current LINE Pay implementation must receive this item-by-item subagent verification after implementation.
+- After the LINE Pay retrospective, the user explicitly required future subagent reviews to use staged, complete reports rather than repeated one-finding-at-a-time loops on 2026-08-12.

Plik diff jest za duży
+ 0 - 0
.claude/homunculus/observations.jsonl


+ 1 - 0
.gitignore

@@ -10,6 +10,7 @@
 .idea/misc.xml
 .idea/vcs.xml
 .superpowers/
+.worktrees/
 /.claude/*.json
 /.claude/settings.local.json
 /.gitignore

+ 0 - 13
.idea/modules.xml

@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectModuleManager">
-    <modules>
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-admin/ruoyi-admin.iml" filepath="$PROJECT_DIR$/ruoyi-admin/ruoyi-admin.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-common/ruoyi-common.iml" filepath="$PROJECT_DIR$/ruoyi-common/ruoyi-common.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-framework/ruoyi-framework.iml" filepath="$PROJECT_DIR$/ruoyi-framework/ruoyi-framework.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-generator/ruoyi-generator.iml" filepath="$PROJECT_DIR$/ruoyi-generator/ruoyi-generator.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-quartz/ruoyi-quartz.iml" filepath="$PROJECT_DIR$/ruoyi-quartz/ruoyi-quartz.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-system/ruoyi-system.iml" filepath="$PROJECT_DIR$/ruoyi-system/ruoyi-system.iml" />
-    </modules>
-  </component>
-</project>

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/016-omg-payment"
+  "feature_directory": "specs/027-offline-transfer-payment"
 }

+ 0 - 1
AGENTS.md

@@ -1 +0,0 @@
-CLAUDE.md

+ 126 - 0
AGENTS.md

@@ -0,0 +1,126 @@
+# foodie_server Codex 项目规范
+
+本文件是 Codex 在仓库根目录直接加载的项目指令。遵循用户最新要求,并按以下规则进行分析、修改和验证。`CLAUDE.md` 保留更完整的背景说明;本文件已包含日常开发必须遵守的关键约束。
+
+## 工作原则
+
+- 动手前确认目标、影响范围和验收方式;存在会实质改变结果的歧义时,先明确说明。
+- 只实现用户要求的内容,不增加推测性功能,不为一次性逻辑引入抽象或配置。
+- 只修改完成任务必需的代码;不顺手重构、格式化或清理无关代码。
+- 匹配现有代码风格。只移除本次修改造成的无用 import、变量或方法。
+- 修复缺陷时优先用可复现检查或测试证明问题,再验证修复结果;多步骤任务给出简短、可验证的执行计划。
+- 当用户说“只改 X”时,只改 X。简单的格式、显示或单文件修改不要创建任务计划,也不要启动 Agent。
+
+## OMG 支付执行纪律与本次复盘
+
+### 已暴露的问题
+
+- 已确认的需求仍被重复分析、确认和审查,导致实现节奏失控。
+- 单个接口被拆成过多小步骤,频繁读取规格、检查状态和执行零散命令,没有一次性收口。
+- 用户已要求全部 OMG 功能完成后统一测试,但实现过程中仍沿用逐测试推进方式;在不运行测试的情况下既没有获得反馈,又浪费了时间。
+- 工具命令失败后曾重新展开分析,而不是直接修正命令并继续。
+- 曾把中间文件修改描述为进展,但源码仍处于接口与实现不一致、不可交付的状态。
+- PowerShell 命令换行使用错误,造成暂存失败;执行前没有按当前 Shell 语法一次写对。
+
+### 后续强制执行规则
+
+- 用户已经确认的 OMG 需求不得重复询问、重新设计或反复论证;只有发现会实质改变结果且无法从仓库确认的新歧义时才能暂停说明。
+- 每个 OMG 接口按一个批次完成生产代码、测试源码、spec-kit 文档和 SQL;不要把同一接口拆成多个等待用户确认或重复审计的小批次。
+- 开始实现前只读取完成当前接口必需的文件;实现过程中不反复运行 `git status`、`rg`、`git diff` 或同类审计命令。
+- 每个接口完成后只进行一次统一静态检查、一次暂存范围检查和一次提交;检查发现问题时直接修复,再做一次最终复核,不重新展开方案设计。
+- 在用户明确要求的本轮 OMG 重做期间,不运行 Maven、编译或测试;等创建、回调、查询、补单、退款等全部计划功能调整完成后,再统一运行 JDK 21 定向测试、模块构建和完整回归。
+- 测试源码可以随接口实现一并编写,但不得借“测试先行”之名增加逐文件、逐方法的工具往返;延后运行测试时必须明确说明测试仅已编写、尚未验证。
+- 工具命令失败时优先直接纠正命令;不得因命令语法、路径或暂存错误重新分析已经确认的业务方案。
+- PowerShell 多路径命令使用数组传参或其他合法 PowerShell 语法,不使用 Bash 风格反斜杠续行。
+- 进度只使用四种状态:`未开始`、`实现中`、`已提交`、`已验证`。未完成提交前统一报告为“实现中”,不得把局部修改、测试源码已写或静态检查部分完成描述成接口已完成。
+- `已提交` 只表示代码已形成独立提交;只有实际运行约定的测试和构建并检查结果后,才能报告为 `已验证`。
+- 如果违反上述任一规则,立即停止当前低效操作,说明违反的具体条款,纠正执行方式后继续;不得只口头承认后仍沿用原方式。
+
+### 可审计交付要求
+
+- 最终交付必须给出提交 SHA、实际执行的检查以及明确未执行的验证项。
+- 提交前核对暂存文件清单,只包含当前 OMG 接口及其规格、测试和 SQL;不得混入工作区原有脏文件。
+- 不依赖“我会遵守”的口头承诺;以后以 `AGENTS.md` 本节、命令记录、暂存清单和提交结果作为执行是否合规的依据。
+
+## 技术栈和相关项目
+
+- 后端:Java、Spring Boot、MyBatis XML Mapper、MySQL。
+- 前端:Vue.js、Element UI。
+- 支持语言:越南语 `vi`、简体中文 `zh`、繁体中文 `tw`、英文 `en`。
+- 平台管理前端:`E:\QtwCode\foodie\foodie-admin-vue`。
+- 商家管理前端:`E:\QtwCode\foodie\foodie-store`。
+- 开发环境以 Windows 为准;编辑时保留文件原有编码和 CRLF/LF 换行风格,不因小改动重写整个文件。
+
+## 订单和支付代码边界
+
+以下代码仍在仓库中但已经废弃,不要参考其业务逻辑,也不要在其上做增量修改:
+
+- `ZaloPayController.java`:整体废弃,ZaloPay 已下线。
+- `NewebpayPayController.java` 与 `PosStoreNewebpayController.java`:蓝新支付已废弃(2026-09-02 确认,从未在生产启用),代码仅保留备查,不要参考其业务逻辑,也不要在其上做增量修改或启用。
+- `PayController.java`:VNPay 回调和多数方法废弃。仅 `sendAcceptRiderPush(...)` 仍被 `PosOrderController` 的货到付款路径引用;等接入新支付时再迁移。
+- `TestTask.java`:其中定时任务全部废弃,包括退款处理、抽成返还、自动开店和超时退款。
+- `OrderAppealController.java`:取消/申诉逻辑已失效,仅剩 `parseLocale` 可用。
+
+当前有效的订单操作入口:
+
+- `PosOrderShOprateController`:商家出餐和取消。
+- `PosOrderQsOprateController`:骑手接单、取餐、送达,使用 `deliveryStatus` 1/2/3。
+- `UserOrderController`:用户下单和取消。
+- `PosOrderController`:仅 `/addorder`、`/setorderuzt` 和 list 查询仍在使用,主要订单状态流转已经迁出。
+
+推送以当前真实通道为准:`PayPush` 没有 Android FCM;本项目仅有 iOS uni 云函数 `msduser`、`msdrider`、`msdstore`,以及 `PushEvent -> push_message` 入库。`cidType` 当前没有实际区分作用。相关状态见 `specs/012-im-user-integration/code-status.md`。
+
+## Spring Controller 规范
+
+- 需要登录 token 的接口直接声明 `@RequestHeader String token`。不要为了读取 token 注入或使用 `HttpServletRequest`。
+- POST 业务参数使用明确 DTO,并显式标注 `@RequestBody`;禁止未标注注解的隐式绑定。
+- GET 查询参数逐个显式标注 `@RequestParam`;token 使用 `@RequestHeader`,路径参数使用 `@PathVariable`。
+- 禁止用任何 `Map` 作为 Controller 请求方法入参。
+- 第三方 `form-urlencoded` 回调也用 DTO(如 `@ModelAttribute`)接收。签名 SDK 必须用 Map 时,只能在 Controller 边界之后由 DTO 转换,不能把 Map 暴露为接口入参。
+- DTO 仅承载请求数据,不在 DTO 字段上使用 Bean Validation 注解,也不依赖 `@Valid` / `@Validated` 返回业务校验错误。
+- 业务校验放在 Controller 或 Service,并通过 `MessageUtils.message(...)` 等项目国际化机制返回错误,禁止硬编码单一语言错误信息。
+
+## 后端构建和模块边界
+
+- Maven 编译目标为 JDK 21。本机使用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`;只在当前命令环境临时设置 `JAVA_HOME` 和 `PATH`,不要修改用户全局 Java 配置。
+- 模块依赖方向必须保持 `ruoyi-admin -> ruoyi-system`。禁止 `ruoyi-system` 反向依赖或导入 `com.ruoyi.app.*`。
+- 同时依赖外部 HTTP(httpclient4 + fastjson2)和应用层 Service 的集成代码放在 `ruoyi-admin`,不要放入 `ruoyi-system`。
+- Java 块注释或 Javadoc 正文中禁止出现额外的 `*/`。描述 `ATM_*`、`CVS_*` 等通配值时,写成“ATM 系列”“CVS 系列”,避免提前关闭注释。
+
+## 数据库和全栈字段变更
+
+- 不直接执行任何数据库结构或数据迁移操作。所有 `ALTER TABLE`、数据迁移等 SQL 写入 `updatesql/sql.md`,标注日期和用途,由开发者统一手动执行。
+- 添加新字段时按顺序检查并更新:Java Entity、MyBatis XML 的 resultMap 和相关 select/insert/update、DTO、Service、Controller、Vue 组件、四个 i18n 文件、SQL 迁移脚本。
+
+## 前端和 i18n
+
+- 平台端和商家端所有新增用户可见文本都必须使用 `$t()`,不得硬编码中文。
+- 商家端语言文件位于 `src/lang/zh.js`、`tw.js`、`en.js`、`vi.js`;新增 key 必须四个文件同时添加,名称完全一致。
+- i18n key 使用有意义的英文驼峰命名,禁止 `text1`、`text2` 等无意义编号。
+- key 必须放进调用路径对应的嵌套对象。例如 `$t('foots.AddCategoryFirst')` 对应的 key 必须位于 `foots` 对象内部。
+- 编辑 `foodie-store` 和 `foodie-admin-vue` 时保留 CRLF。先确认原始换行风格,避免字符串替换失败或造成整文件换行变化。
+
+## 商家和订单查询规则
+
+- `InfoUser.userType = 1`(普通商家):使用 `PosOrder.shId = userId`。
+- `InfoUser.userType = 3`(夜市商家):使用 `PosOrder.shId = userId`。
+- 其他摊位商家:先读取 `InfoUser.storeId`,再使用 `PosOrder.mdId = InfoUser.storeId`。
+- 字段含义:`InfoUser.userType` 为 0 用户、1 商家、2 骑手、3 夜市;`PosOrder.shId` 是商家 ID,`PosOrder.mdId` 是门店 ID。
+
+## spec-kit 和规格文件
+
+- 用户提到 `spec kit`、`spec-kit` 或 `speckit` 时,指 GitHub `github/spec-kit`;项目配置在 `.specify/`,规格在 `specs/`。
+- 新功能默认遵循 `specify -> plan -> tasks -> implement`。
+- 已有功能追加或变更时,更新现有 `spec.md`、`plan.md`、`tasks.md` 并顺延任务;除非用户明确要求,不重新启动完整流程。
+- OMG 支付相关工作开始前阅读 `specs/016-omg-payment/plan.md`。
+
+## 特定业务约束
+
+- 餐桌码必须覆盖普通商家,不能只按夜市摊主或夜市管理员设计权限;普通商家也能查看自己门店的餐桌码及关联订单。
+- 涉及支付、退款、配送和订单状态时,以当前有效入口和最新规格为准;不要把仅修改数据库状态等同于真实支付、退款或配送流程。
+
+## 验证要求
+
+- 根据改动风险运行最小且充分的检查;优先运行受影响模块的编译、单元测试或定向测试。
+- 交付前检查 `git diff`,确认没有无关文件、整文件格式化、编码或换行变化。
+- 如果受环境或外部服务限制无法完成验证,明确说明未验证项和原因,不把推测描述为已验证结果。

+ 36 - 4
CLAUDE.md

@@ -1,6 +1,6 @@
 # foodie_server Development Guidelines
 
-Auto-generated from all feature plans. Last updated: 2026-05-15
+Auto-generated from all feature plans. Last updated: 2026-08-18
 
 ## Tech Stack
 
@@ -26,7 +26,8 @@ Auto-generated from all feature plans. Last updated: 2026-05-15
 | 文件 | 状态 | 备注 |
 |------|------|------|
 | `ZaloPayController.java` | 整体废弃 | ZaloPay 支付已下线 |
-| `PayController.java` | VNPay 回调及多数方法废弃 | **特例**:`sendAcceptRiderPush(...)` 仍被 `PosOrderController` 货到付款路径与 `NewebpayPayController`(未启用)引用;迁移推迟到接入新支付时 |
+| `NewebpayPayController.java` / `PosStoreNewebpayController.java` | 蓝新支付已废弃(2026-09-02 确认) | 从未在生产启用;代码保留备查,不要参考或增量修改 |
+| `PayController.java` | VNPay 回调及多数方法废弃 | **特例**:`sendAcceptRiderPush(...)` 仍被 `PosOrderController` 货到付款路径引用;迁移推迟到接入新支付时 |
 | `TestTask.java` | 定时任务全部废弃 | 含 refundProcessing / 抽成返还 / 自动开店 / 超时退款;相关推送当前均不存在 |
 | `OrderAppealController.java` | 取消/申诉整片注释 | 仅留 `parseLocale`;取消/申诉推送全部失效 |
 
@@ -34,7 +35,6 @@ Auto-generated from all feature plans. Last updated: 2026-05-15
 - `PosOrderShOprateController` — 商家操作(出餐 chuCan、商家取消)
 - `PosOrderQsOprateController` — 骑手操作(接单/取餐/送达,按 `deliveryStatus` 1/2/3)
 - `UserOrderController` — 用户操作(下单、用户取消)
-- `NewebpayPayController` — 蓝新金流支付,**代码就绪但尚未启用**;支付链路含 `sendAcceptRiderPush` 迁移,等接入新支付时再做
 - `PosOrderController` — 仅 `/addorder`(下单,含货到付款 sendHdfkMessage)、`/setorderuzt`(平台改状态)及 list 查询在用;订单状态流转已迁出
 
 **推送通道:** `PayPush` 无 Android FCM(cte_server 有,本项目砍掉),仅 iOS uni 云函数(`msduser/msdrider/msdstore`)+ 入库(`PushEvent`→`push_message`);`cidType` 形同虚设(到处传 `""`)。详见 `specs/012-im-user-integration/code-status.md`。
@@ -103,6 +103,38 @@ ALTER TABLE pos_order ADD COLUMN delivery_status BIGINT DEFAULT NULL COMMENT '
 
 **对于简单的格式化或显示变更**(如截断小数、重命名 key、单文件修改),**不要**创建任务计划或启动 Agent,直接做编辑。
 
+## Controller 请求参数规范
+
+创建或修改任何 Spring Controller 时,必须遵守以下规则:
+
+1. 需要登录 token 的接口必须直接声明 `@RequestHeader String token`,禁止通过 `HttpServletRequest` 读取 token,也不得仅为读取 token 而注入 `HttpServletRequest`。
+2. POST 接口接收业务参数时,必须使用类型明确的 DTO,并在参数前显式添加 `@RequestBody`,例如 `@RequestBody OrderDTO orderDTO`;禁止使用未标注注解的隐式绑定。
+3. GET 接口接收查询参数时,每个查询参数必须显式添加 `@RequestParam`,例如 `@RequestParam Integer page`;token 仍使用 `@RequestHeader`,URL 路径变量仍使用 `@PathVariable`。
+4. 禁止使用 `Map<String, String>`(或其它 Map)作为请求处理方法的入参。
+5. 第三方 `form-urlencoded` 回调同样使用 DTO(例如 `@ModelAttribute`)接收;如签名 SDK 必须使用 Map,只允许在 Controller 边界之后由 DTO 转换为内部 Map,不得把 Map 暴露为接口入参。
+6. DTO 只用于承载请求数据,禁止在 DTO 字段上使用 `@NotNull`、`@NotBlank`、`@Size` 等 Bean Validation 注解,也不要依赖 Controller 参数上的 `@Valid` / `@Validated` 返回校验错误。业务参数校验必须放在 Controller 或 Service 中,并通过 `MessageUtils.message(...)` 等项目国际化机制返回错误信息,禁止硬编码仅支持单一语言的校验消息。
+
+## 后端构建与模块边界
+
+1. 本项目 Maven 编译目标为 JDK 21。当前机器执行构建时使用 `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`,只在当前命令环境临时设置 `JAVA_HOME` 和 `PATH`,不要修改用户的全局 Java 配置。
+2. 模块依赖方向为 `ruoyi-admin -> ruoyi-system`,禁止 `ruoyi-system` 反向依赖或导入 `com.ruoyi.app.*`。
+3. 同时依赖外部 HTTP(项目使用 httpclient4 + fastjson2)与应用层 Service 的集成代码应放在 `ruoyi-admin`,不要放入 `ruoyi-system`。
+4. 后端 Java/XML/YAML 与前端文件均可能使用 CRLF;编辑时保留原换行风格,禁止因小改动格式化或重写整个文件。
+
+## spec-kit 工作流
+
+1. 用户提到 `spec kit`、`spec-kit` 或 `speckit` 时,指 GitHub `github/spec-kit`,项目配置位于 `.specify/`,规格位于 `specs/`。
+2. 新功能默认按 `specify -> plan -> tasks -> implement` 流程进行。
+3. 已有功能追加或变更需求时,直接更新现有 `spec.md`、`plan.md`、`tasks.md` 并顺延任务,不重新启动完整 spec-kit 流程,除非用户明确要求。
+
+## Java 注释安全
+
+Java 块注释或 Javadoc 的正文中禁止出现额外的 `*/` 字符序列。描述 `ATM_*`、`CVS_*` 等通配值时,改写为“ATM 系列”“CVS 系列”,避免提前关闭注释导致编译失败。
+
+## 餐桌码适用范围
+
+餐桌码功能必须覆盖普通商家,不得仅按夜市摊主或夜市管理员设计权限。普通商家也可以查看自己门店的餐桌码及其关联订单。
+
 <!-- MANUAL ADDITIONS END -->
 # CLAUDE.md
 
@@ -172,5 +204,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/016-omg-payment/plan.md`
+shell commands, and other important information, read the current plan: `specs/027-offline-transfer-payment/plan.md`
 <!-- SPECKIT END -->

+ 103 - 0
docs/app-install-attribution.md

@@ -0,0 +1,103 @@
+# App 推广安装归因方案(讨论稿)
+
+> 记录时间:2026-09-02
+> 状态:方案讨论完成,待管理层拍板三个决策点后按 spec-kit 立项(规格 → 计划 → 任务 → 实现)
+> 需求:推广人员分享推荐链接,他人通过链接去应用商店下载 App 后,系统能关联"这个用户是谁推荐的"
+
+## 1. 现状基础
+
+- `info_user` 已有 `mycode`(本人 8 位邀请码,注册时生成)和 `thiscode`(记录邀请人)字段;`/setuser` 已能接收 `thiscode` 入库。
+- 缺失部分:推荐码从分享到安装的传递链路、归因记录与注册自动绑定、邀请奖励结算逻辑(当前 `thiscode` 只记录不结算)。
+
+## 2. 核心难点
+
+- **Android**:Google Play 官方提供 Install Referrer API,下载来源可 100% 自动传递。
+- **iOS**:苹果隐私规则禁止 App 下载过程传递任何来源信息(平台级限制,所有 App 一致,无技术绕过方案)。iOS 归因必然需要"一次用户轻交互"或接受模糊匹配。
+- 用户不点链接、直接去商店搜索下载的,技术上无法归因,只能靠手填邀请码兜底。
+
+## 3. 分平台方案
+
+### 3.1 Android:Play Install Referrer(100% 准确,无感)
+
+```
+分享链接:https://play.google.com/store/apps/details?id={包名}&referrer={推广令牌}
+App 首启调 Play Install Referrer API 读出令牌 → 首启上报 → 注册自动绑定
+```
+
+### 3.2 iOS:按可靠度分层的通道组合
+
+| 优先级 | 通道 | 原理 | 用户成本 | 准确率 |
+|---|---|---|---|---|
+| 1 | **手机号中转** | 落地页"输手机号领新客券",服务端记录(手机号 → 推广令牌);注册用同一手机号时精确匹配自动绑定 | 填一次手机号 | ≈100% |
+| 1' | 快速预注册(中转页变体) | 落地页直接手机号+验证码预建账号,下载后登录即归因;适合不给奖励的场景 | 完成注册 | ≈100% |
+| 2 | **复制邀请码** | 落地页"复制"按钮把 `https://域名/r/{令牌}` 写入系统剪贴板(设备级全局存储,装 App 不清空);注册页输入框出现 iOS 系统粘贴建议条,点一下自动填 | 点两下 | ≈100%(依赖配合) |
+| 2' | 剪贴板无弹窗自动读取 | iOS 16+ `UIPasteboard.detectPatterns` 可无弹窗检测剪贴板内 URL 并取值;因此复制内容必须包成 URL 而非裸码。时机必须在注册页而非启动页 | 无感 | 高(iOS 16+) |
+| 3 | **指纹匹配** | 落地页记录 IP+UA(机型/系统)+屏幕+点击时间;App 首启上报同字段,服务端窗口期配对 | 无 | 60–80% |
+| 4 | **手填邀请码** | 推广人 LINE 里直接发码,注册页输入框手输 | 手输 | 100% |
+
+注意事项:
+
+- 剪贴板是单槽位,复制码后用户若复制其他内容(下载窗口期内很常见)即被覆盖丢失,故剪贴板只做增强层,不做主力。
+- 不要在 App 启动页读剪贴板(iOS 16 弹"允许粘贴"确认框,用户拒绝即失效且伤体验)。
+- iOS 15 及以下直接读剪贴板仅显示顶部通知不拦截。
+- Apple 官方 AdAttributionKit/SKAN 只服务广告网络,不适用于个人分享链接。
+- Universal Link 仅对已安装用户有效,不适用新装机。
+
+## 4. 归因架构(两端共用)
+
+采用"首启上报、服务端待归因、注册自动绑定"三段式,**不依赖客户端在注册时提交推荐码**:
+
+```text
+推广人生成分享链接(服务端签发不透明令牌,非裸 mycode,可验真/撤销/统计)
+        ↓
+落地页(记录指纹 + 手机号/复制码;Android 直跳 Play 带 referrer)
+        ↓
+App 首启 → POST /app/install/attribution(令牌 + 设备信息)
+        ↓
+服务端待归因记录:设备指纹/手机号 + 令牌 + 上报时间(状态=待绑定)
+        ↓
+用户注册 → 服务端自动查待归因记录 → 绑定 thiscode → 记录置为已消费
+```
+
+绑定优先级(需写死):**手填邀请码 > 手机号中转 > 剪贴板 > 指纹匹配**(用户明确意图优先)。
+
+## 5. 必须定死的规则
+
+1. **归因窗口**:安装后 7~30 天内注册有效(建议 7 天),过期作废。
+2. **一机一记录**:待归因记录消费后作废;同设备重装重复上报不重复发奖。
+3. **多来源冲突**:last-click wins(最后点击生效);同 IP 窗口期内多条不同令牌的点击,保守处理(放弃归因或取最后一条)。
+4. **指纹误判保守策略**:匹配不上宁可算自然流量,不错归。
+5. **防刷**:服务端令牌签发、设备唯一性校验、奖励冷却期;裸推广码不可作为链接参数(防手编)。
+6. **iOS 剪贴板失效兜底链**:手机号中转(不受剪贴板影响)→ 指纹(不受剪贴板影响)→ 手填。
+
+## 6. 预期归因率
+
+| 场景 | 归因率 |
+|---|---|
+| Android(点了链接) | ≈100%(Install Referrer) |
+| iOS 走了手机号中转 | ≈100% |
+| iOS 只点了复制码、剪贴板未被覆盖 | ≈100% |
+| iOS 剪贴板被覆盖、无手机号 | 指纹兜住 60–80% |
+| 未点链接直接商店搜索 | 0%(手填码除外) |
+
+iOS 综合预期 85% 以上(取决于落地页"填手机号/复制码"的转化率);其余算自然流量。
+
+## 7. 落地节奏与工作量粗估
+
+- **第一期(自建,无采购成本)**:推广短链/令牌服务 + 落地页(含领券/复制交互,需适配 LINE 内建浏览器引导外部浏览器打开)+ 归因上报接口 + 待归因表 + 注册绑定逻辑 + App 双端改造(Android 读 referrer、iOS 粘贴建议条/首启上报)。
+  - 后端 + 落地页约 1–2 周;App 双端约 1–2 周;含联调测试合计约 2–4 周。
+- **第二期(规模化可选)**:接入 AppsFlyer OneLink / Adjust,短链全平台自动归因 + 推广数据报表;有按量费用。
+- 隐私合规:设备指纹与手机号用于营销归因,需写入 App 隐私政策(台湾个资法);落地页附用途说明。
+
+## 8. 待拍板决策点
+
+1. **iOS 方案**:方案 A 落地页填手机号领新客券(≈100%,需券预算)还是方案 B 零成本复制码+手填(综合 70–80%)。
+2. **新客优惠券**:面额与预算(零成本也建议放"首单免配送费/95 折"类轻钩子,否则中转页填写率极低)。
+3. **推荐奖励规则**:推荐人得什么(只记录 / 注册即奖 / 首单奖励 / 持续分成)——决定要不要建邀请关系表与结算逻辑。
+
+## 9. 立项后范围(spec-kit)
+
+- `specs/0xx-app-referral-attribution/spec.md`:用户故事(推广人生成链接、被邀请人下载注册自动绑定、推荐数据查询)、验收场景、防刷规则。
+- 后端:短链/令牌、落地页、归因上报、待归因表、注册绑定、(可选)邀请奖励结算。
+- App 端需求单:Android Install Referrer 读取、iOS 粘贴建议条/URL 模式识别、首启上报。
+- 平台端(可选):推广关系与归因记录查询页面。

+ 1127 - 0
docs/flash-delivery-app-api.md

@@ -0,0 +1,1127 @@
+# 闪送功能 App 接口接入文档
+
+本文档供用户端 App 和骑手端 App 接入闪送功能使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。
+
+> 更新时间:2026-09-09。新增待接单订单修改发件/收件信息与追加小费:修改地址先重报价,用户确认后保存;保存时校验订单状态和版本。闪送仍不接入支付。
+
+当前下单与订单编辑契约要点:
+
+- `serviceType` 只区分 `HELP_SEND`(帮送)和 `HELP_PICKUP`(帮取),不再使用 `URGENT` 作为服务类型。
+- 是否加急由独立字段 `deliveryType=NORMAL/URGENT` 表达;帮送和帮取使用同一套分时段基础运价。
+- 报价和创建订单都必须提交取件方式、物品类别、数量、四档重量范围、规格说明和骑手小费;立即取件使用 `deliveryMode=NOW`,预约取件使用 `deliveryMode=SCHEDULED` 并提交取件时间段。
+- App 必须先报价,再把报价 ID、版本及四个费用字段原样回传创建订单接口。后端重新计算不一致时不会创建订单,而会返回最新报价。
+
+- 本次新增两个业务操作、三个 POST 接口:修改地址预报价 `quoteAddress`、确认保存 `updateAddress`,以及追加小费 `addTip`,详见 7.8—7.10。
+- 三个新增接口仅供订单创建人操作仍未被骑手接走的订单,必须回传用户详情中的 `orderVersion`;追加小费传本次增量 `additionalTipAmount`。
+
+## 1. 功能范围
+
+当前闪送支持:
+
+- 帮送、帮取两种业务场景,以及普通配送、1 对 1 加急两种配送等级。
+- 物品类别、数量、设计稿四档重量范围和体积/规格。
+- 基础配送费、距离费、加急费、骑手小费和订单总金额明细。
+- 共享地址簿、路线报价、立即配送和预约配送。
+- 待接单订单修改发件/收件地址、联系人和电话,重新报价并确认保存;追加小费并同步更新总金额。
+- 用户发布订单,并按“我发的/我收的”查询订单;发件人可取消,发件人或当前绑定的收件人可确认收货。
+- 骑手查看待抢订单、抢单、确认取件和确认送达。
+- 可选的四位交付 PIN。
+- 寄件、取件和送达图片凭证。
+- 送达 24 小时后仍未由用户确认的订单自动完成。
+
+当前不包含支付、退款、骑手收入、结算、代购垫付、自动派单和骑手放弃订单。
+
+设计原型中的支付方式选择、已付款状态、附近骑手上线数量、预计接单时间和骑手收益金额均为静态示意,接口不提供这些数据,App 不应展示或请求它们。发布成功页统一使用静态文案“发布后等待附近骑手接单”。
+
+## 2. 通用约定
+
+### 2.1 基础地址和请求头
+
+接口路径均为相对路径,实际请求地址为:
+
+```text
+{baseUrl}{接口路径}
+```
+
+除特别说明外,用户端和骑手端接口都必须携带以下请求头:
+
+| 请求头 | 必填 | 说明 |
+|---|---:|---|
+| `token` | 是 | App 登录后取得的 JWT。不要放在 `Authorization` 中 |
+| `Content-Type` | POST JSON 接口必填 | `application/json` |
+
+用户身份和骑手身份均由 `token` 解析,请求体中不提交 `userId` 或 `riderId`。
+
+### 2.2 统一响应
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {}
+}
+```
+
+无返回数据的成功响应通常不含 `data`:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功"
+}
+```
+
+业务失败响应:
+
+```json
+{
+  "code": 500,
+  "msg": "当前订单状态不允许此操作"
+}
+```
+
+登录失效响应:
+
+```json
+{
+  "code": 401,
+  "msg": "token已过期,请重新登录!"
+}
+```
+
+App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 状态码。`msg` 已由后端国际化,可直接用于错误提示。
+
+### 2.3 分页响应
+
+列表接口的分页数据位于 `data` 中,不使用若依传统的顶层 `rows/total`:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "records": [],
+    "total": 0,
+    "current": 1,
+    "size": 10,
+    "pages": 0
+  }
+}
+```
+
+- `page` 默认 `1`,小于 `1` 时按 `1` 处理。
+- `size` 默认 `10`,范围为 `1` 至 `100`。
+- App 至少读取 `records`、`total`、`current` 和 `size`。
+
+### 2.4 时间、金额和距离
+
+- 请求中的预约时间使用 ISO 日期时间字符串,例如 `2026-09-02T10:00:00+08:00`。
+- 返回时间以服务端实际 JSON 时间格式为准,App 应按日期时间解析,不应依赖固定展示格式。
+- 金额单位为新台币,`currency` 固定为 `TWD`;`startingFare`、`freight`、`distanceFee`、`baseDeliveryFee`、`minimumUrgentFee`、`urgentFee`、`tipAmount` 和 `amount` 都是整数,不带小数。
+- `distanceMeters` 单位为米。
+- 运价配置中的 `startingDistance`、`distance` 以及报价中的 `billableDistance` 单位为公里,可保留两位小数。
+- `estimatedDurationSeconds` 单位为秒,路线服务降级时可能为 `null`。
+- `distanceSource` 为 `ROUTE` 时表示地图驾车路线,为 `STRAIGHT_LINE` 时表示地图服务不可用后使用直线距离降级。
+
+## 3. 枚举和状态
+
+### 3.1 服务类型 `serviceType`
+
+| 值 | 含义 |
+|---|---|
+| `HELP_SEND` | 帮送 |
+| `HELP_PICKUP` | 帮取 |
+
+`serviceType` 只表达业务场景,不再表达是否加急。帮送和帮取共享同一套运价时段。
+
+### 3.2 配送等级 `deliveryType`
+
+| 值 | 含义 |
+|---|---|
+| `NORMAL` | 普通配送,加急费固定为 0 |
+| `URGENT` | 1 对 1 加急配送,收取加急费并启用骑手独占规则 |
+
+### 3.3 包裹类型 `packageType`
+
+| 值 | 含义 |
+|---|---|
+| `DOCUMENT` | 文件 |
+| `GIFT` | 礼品 |
+| `CLOTHING` | 服饰 |
+| `BEAUTY` | 美妆 |
+| `DAILY_NECESSITIES` | 日用品 |
+| `FOOD_INGREDIENTS` | 食材 |
+| `ELECTRONICS` | 数码产品 |
+| `SMALL_APPLIANCE` | 小家电 |
+| `OTHER` | 其他 |
+
+### 3.4 物品重量范围 `weightRange`
+
+| 接口值 | 连续重量区间 | UI 文案 |
+|---|---|---|
+| `UP_TO_5_KG` | 大于 0 且不超过 5 公斤 | 5 公斤以内 |
+| `OVER_5_TO_10_KG` | 超过 5 且不超过 10 公斤 | 6–10 公斤 |
+| `OVER_10_TO_15_KG` | 超过 10 且不超过 15 公斤 | 11–15 公斤 |
+| `OVER_15_TO_20_KG` | 超过 15 且不超过 20 公斤 | 16–20 公斤 |
+
+App 必须直接提交上述四个枚举之一,不再提交精确重量 `totalWeightKg`,也不再提交旧三档字段 `packageSize`。API 使用连续区间定义,UI 按设计稿显示整数范围;例如实际估重 5.5 公斤应选择 `OVER_5_TO_10_KG`。
+
+`weightRange` 仅用于物品申报、订单展示和骑手判断是否适合承运,当前不参与基础配送费、距离费、加急费或总金额计算;选择不同重量范围不会改变报价。
+
+### 3.5 配送方式 `deliveryMode`
+
+| 值 | 含义 |
+|---|---|
+| `NOW` | 立即配送,也是未传值时的默认值 |
+| `SCHEDULED` | 预约配送 |
+
+预约配送要求:
+
+- `scheduledPickupStartAt` 和 `scheduledPickupEndAt` 都必填。
+- 开始时间不得早于当前时间,且不得晚于创建订单时刻后三天。
+- 结束时间必须比开始时间晚 30 分钟。
+- 预约订单创建后仍为 `WAITING_ACCEPTANCE`,但在预约开始时间到达前不会出现在骑手待抢列表中。
+
+立即配送时不得传 `scheduledPickupStartAt` 和 `scheduledPickupEndAt`。
+
+### 3.6 订单状态 `status`
+
+```text
+WAITING_ACCEPTANCE -> ACCEPTED -> PICKED_UP -> DELIVERED -> COMPLETED
+          |               |
+          +--- 发件人可取消 +--- 发件人可取消
+```
+
+| 值 | 含义 | 下一步 |
+|---|---|---|
+| `WAITING_ACCEPTANCE` | 待骑手接单 | 骑手抢单;发件人可修改取送信息、追加小费或取消 |
+| `ACCEPTED` | 骑手已接单 | 骑手确认取件,或发件人取消 |
+| `PICKED_UP` | 骑手已取件 | 骑手确认送达 |
+| `DELIVERED` | 骑手已送达 | 发件人或当前绑定的收件人确认收货;超过 24 小时可由系统自动完成 |
+| `COMPLETED` | 已完成 | 终态 |
+| `CANCELLED` | 已取消 | 终态 |
+
+## 4. 推荐调用流程
+
+### 4.1 用户端
+
+1. 调用首页接口取得当前可用服务及价格摘要。
+2. 从共享地址簿选择地址,或填写取件和收件信息。
+3. 调用报价接口,展示服务端返回的路线和费用。
+4. 用户确认后调用创建订单接口。创建时服务端会重新计算路线和金额,最终以创建响应为准。
+5. 使用 `role=sender` 查看“我发的”,使用 `role=receiver` 查看“我收的”,并通过列表或详情刷新状态。当前闪送模块没有 App 实时推送接口。
+6. 在“我发的”待接单详情页,使用最新 `orderVersion` 调用地址预报价与确认保存接口,或调用追加小费接口;每次成功后使用返回的完整详情刷新页面及版本。
+7. 仅发件人可在 `WAITING_ACCEPTANCE` 或 `ACCEPTED` 状态取消;发件人和当前绑定的收件人都可在 `DELIVERED` 状态确认收货。
+
+### 4.2 骑手端
+
+1. 调用待抢列表查看当前可接订单。
+2. 可先调用详情接口查看同一份脱敏摘要。
+3. 调用抢单接口。只有抢单成功后才能取得完整联系人和精确地址。
+4. 到达取件点后先上传图片,再调用确认取件接口。
+5. 到达收件点后先上传图片;如订单启用 PIN,向收件人取得四位 PIN,再调用确认送达接口。
+
+抢单为并发原子操作。即使列表中仍显示订单,也可能已被其他骑手抢走;收到失败响应后应刷新待抢列表。
+
+## 5. 图片上传
+
+闪送订单接口不接收文件,只接收上传完成后的 HTTP(S) 图片 URL。
+
+App 可复用现有上传接口:
+
+| 方法 | 路径 | Content-Type | 参数 |
+|---|---|---|---|
+| POST | `/utils/Upload` | `multipart/form-data` | 文件字段名 `file` |
+
+成功响应示例:
+
+```json
+{
+  "code": 200,
+  "msg": "上传成功",
+  "data": "/profile/upload/2026/09/01/example.jpg"
+}
+```
+
+该接口返回的 `data` 可能是相对资源路径。提交给闪送接口前,必须补全为外部可访问的绝对 URL,例如:
+
+```text
+https://api.example.com/profile/upload/2026/09/01/example.jpg
+```
+
+图片 URL 规则:
+
+- 只接受 `http://` 或 `https://` URL,必须包含主机名。
+- 单个 URL 最长 1000 个字符。
+- 寄件图片 `senderImageUrls` 可不传,最多 9 张。
+- 取件图片和送达图片各至少 1 张、最多 9 张。
+
+## 6. 公共数据对象
+
+### 6.1 下单地址对象
+
+报价、创建订单及修改订单地址中的 `pickup`、`delivery` 使用以下结构:
+
+```json
+{
+  "name": "王小明",
+  "phone": "0912345678",
+  "address": "台北市信义区市府路1号",
+  "addressDetail": "3楼A室",
+  "city": "台北市",
+  "area": "信义区",
+  "handoffMethod": "请电话联系",
+  "longitude": 121.5645,
+  "latitude": 25.033
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `name` | string | 是 | 最长 64 字符 |
+| `phone` | string | 是 | 最长 32 字符 |
+| `address` | string | 是 | 完整主地址,最长 255 字符 |
+| `addressDetail` | string | 否 | 楼层、门牌等,最长 255 字符 |
+| `city` | string | 否 | 最长 64 字符;用于骑手抢单前的区域展示 |
+| `area` | string | 否 | 最长 64 字符;用于骑手抢单前的区域展示 |
+| `handoffMethod` | string | 否 | 交接说明,最长 40 字符 |
+| `longitude` | number | 是 | `-180` 至 `180` |
+| `latitude` | number | 是 | `-90` 至 `90` |
+
+取件和收件地址不能是相同地址文本,也不能使用完全相同的经纬度。
+
+### 6.2 订单字段
+
+App 完整详情使用以下订单字段,响应的 `data` 直接返回这些字段,不再套用 `order`、`images`、`logs` 包装。
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | number | 订单主键;确认修改地址和追加小费时,将此值放入请求体 `orderId` |
+| `orderVersion` | integer | 用户详情新增的订单版本;修改地址及追加小费必传,成功后递增。用户列表和骑手响应不返回 |
+| `orderNo` | string | 闪送订单号 |
+| `serviceType` | string | 业务场景:`HELP_SEND` 或 `HELP_PICKUP` |
+| `deliveryType` | string | 配送等级:`NORMAL` 或 `URGENT` |
+| `status` | string | 当前状态 |
+| `packageType` | string | 包裹类型 |
+| `quantity` | integer | 物品数量,正整数 |
+| `weightRange` | string | 用户选择的四档重量范围,取值见 3.4 |
+| `specification` | string/null | 体积或规格说明,最长 255 字符 |
+| `deliveryMode` | string | `NOW` 或 `SCHEDULED` |
+| `scheduledPickupStartAt` | datetime/null | 预约开始时间 |
+| `scheduledPickupEndAt` | datetime/null | 预约结束时间 |
+| `pinRequired` | boolean | 是否需要交付 PIN |
+| `pickup` | object | 取件地址快照,结构见 6.1 |
+| `delivery` | object | 收件地址快照,结构见 6.1 |
+| `distanceMeters` | number | 配送距离,单位米 |
+| `distanceSource` | string | `ROUTE` 或 `STRAIGHT_LINE` |
+| `estimatedDurationSeconds` | number/null | 预计时长,单位秒 |
+| `baseDeliveryFee` | integer | 基础配送费,即起送价加距离费 |
+| `distanceFee` | integer | 超出起送距离后的距离费 |
+| `urgentFee` | integer | 加急费;普通配送为 0 |
+| `tipAmount` | integer | 用户填写的骑手小费 |
+| `amount` | integer | 订单金额,整数 TWD |
+| `currency` | string | `TWD` |
+| `userNote` | string/null | 用户备注 |
+| `acceptedAt` | datetime/null | 接单时间 |
+| `pickedUpAt` | datetime/null | 取件时间 |
+| `deliveredAt` | datetime/null | 送达时间 |
+| `completedAt` | datetime/null | 完成时间 |
+| `cancelledAt` | datetime/null | 取消时间 |
+| `cancelReason` | string/null | 取消原因 |
+| `createTime` | datetime | 创建时间 |
+| `updateTime` | datetime | 更新时间 |
+
+用户列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。
+
+骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,distanceFee,urgentFee,tipAmount,amount,currency,payType,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回内部加急比例、最低加急费或骑手收入字段。
+
+### 6.3 App 完整详情对象
+
+用户创建订单、用户订单详情、中单骑手详情及抢单成功响应的 `data` 结构为:
+
+```json
+{
+  "id": 101,
+  "orderNo": "FD1234567890",
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "status": "ACCEPTED",
+  "packageType": "DOCUMENT",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "deliveryMode": "NOW",
+  "scheduledPickupStartAt": null,
+  "scheduledPickupEndAt": null,
+  "pinRequired": true,
+  "pickup": {},
+  "delivery": {},
+  "distanceMeters": 4200,
+  "distanceSource": "ROUTE",
+  "estimatedDurationSeconds": 900,
+  "baseDeliveryFee": 72,
+  "distanceFee": 12,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
+  "currency": "TWD",
+  "payType": "4",
+  "userNote": null,
+  "acceptedAt": "2026-09-01T10:20:00+08:00",
+  "pickedUpAt": null,
+  "deliveredAt": null,
+  "completedAt": null,
+  "cancelledAt": null,
+  "cancelReason": null,
+  "createTime": "2026-09-01T10:00:00+08:00",
+  "updateTime": "2026-09-01T10:20:00+08:00",
+  "senderImageUrls": [
+    "https://api.example.com/profile/upload/sender-1.jpg"
+  ],
+  "pickupImageUrls": [],
+  "deliveryImageUrls": [],
+  "rider": {
+    "name": "陈骑手",
+    "avatar": "https://api.example.com/avatar.jpg",
+    "rating": 4.9,
+    "imUserId": "10086",
+    "longitude": 121.56,
+    "latitude": 25.03
+  },
+  "deliveryPinCode": "4821"
+}
+```
+
+- 用户创建、详情及编辑成功的响应还包含 `orderVersion`;骑手响应不包含。编辑前从用户详情取得最新版本,每次成功后替换本地版本。
+- `senderImageUrls`、`pickupImageUrls` 和 `deliveryImageUrls` 分别表示寄件、取件和送达图片数组。
+- App 详情不返回原始状态日志;页面使用 `status`、`acceptedAt`、`pickedUpAt`、`deliveredAt`、`completedAt` 和 `cancelledAt` 展示进度。
+- `deliveryPinCode` 只向订单发件人和当前绑定的收件人返回,并且只在启用 PIN 时出现;骑手响应永不返回此字段。
+- `rider` 只向用户返回。订单未接单时为 `null`。骑手对象不包含真实电话号码,联系入口使用 `imUserId` 打开 IM 会话,原型中的通话按钮没有接口支撑。
+- 骑手位置只在订单状态为 `ACCEPTED` 或 `PICKED_UP` 时向用户返回,其他状态下经纬度为 `null`。
+
+平台订单详情是审计专用契约,仍可返回 `{order,images,logs}`;App 不应依赖或解析该结构。
+
+## 7. 用户端接口
+
+所有接口均要求用户登录 `token`。
+
+### 7.1 获取闪送首页配置
+
+```http
+GET /system/flashDelivery/home
+```
+
+响应 `data`:
+
+```json
+{
+  "serviceTypes": ["HELP_SEND", "HELP_PICKUP"],
+  "deliveryTypes": ["NORMAL", "URGENT"],
+  "services": [
+    {
+      "serviceType": "HELP_SEND",
+      "pricingId": 1,
+      "startTime": "00:00",
+      "endTime": "08:00",
+      "startingDistance": 3.00,
+      "startingFare": 60,
+      "distance": 1.00,
+      "freight": 10,
+      "urgentRate": 20.00,
+      "minimumUrgentFee": 15,
+      "pricingVersion": 1,
+      "currency": "TWD"
+    },
+    {
+      "serviceType": "HELP_PICKUP",
+      "pricingId": 1,
+      "startTime": "00:00",
+      "endTime": "08:00",
+      "startingDistance": 3.00,
+      "startingFare": 60,
+      "distance": 1.00,
+      "freight": 10,
+      "urgentRate": 20.00,
+      "minimumUrgentFee": 15,
+      "pricingVersion": 1,
+      "currency": "TWD"
+    }
+  ]
+}
+```
+
+帮送和帮取会返回两条业务场景数据,但两条数据引用同一个 `pricingId`、`pricingVersion` 和费用配置。`startTime` 包含、`endTime` 不包含,均为台北时区的 `HH:mm`;`24:00` 只会作为结束时间。`services` 为空表示当前时刻没有匹配的统一运价。
+
+### 7.2 获取实时报价
+
+```http
+POST /system/flashDelivery/quote
+```
+
+请求体:
+
+```json
+{
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
+  "packageType": "DOCUMENT",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
+  "pickup": {
+    "name": "王小明",
+    "phone": "0912345678",
+    "address": "台北市信义区市府路1号",
+    "addressDetail": "3楼A室",
+    "city": "台北市",
+    "area": "信义区",
+    "handoffMethod": "请电话联系",
+    "longitude": 121.5645,
+    "latitude": 25.033
+  },
+  "delivery": {
+    "name": "李小华",
+    "phone": "0987654321",
+    "address": "台北市大安区信义路三段56号",
+    "addressDetail": "1楼",
+    "city": "台北市",
+    "area": "大安区",
+    "handoffMethod": "交给本人",
+    "longitude": 121.538,
+    "latitude": 25.0335
+  }
+}
+```
+
+报价请求字段:
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `serviceType` | string | 是 | `HELP_SEND` 或 `HELP_PICKUP` |
+| `deliveryType` | string | 是 | `NORMAL` 或 `URGENT` |
+| `deliveryMode` | string | 否 | `NOW` 或 `SCHEDULED`,不传按 `NOW` 处理 |
+| `scheduledPickupStartAt` | datetime | 预约时是 | `SCHEDULED` 时为未来三天内的预约开始时间;`NOW` 时不得传 |
+| `scheduledPickupEndAt` | datetime | 预约时是 | 必须比开始时间晚 30 分钟;`NOW` 时不得传 |
+| `packageType` | string | 是 | 取值见 3.3 |
+| `quantity` | integer | 是 | 大于 0 的整数 |
+| `weightRange` | string | 是 | 取值见 3.4,只能提交四个重量范围枚举之一 |
+| `specification` | string | 否 | 体积或规格说明,去除首尾空格后最长 255 字符 |
+| `tipAmount` | integer | 是 | 骑手小费,非负整数 TWD;没有小费时传 `0` |
+| `pickup` | object | 是 | 完整取件信息,结构和规则见 6.1 |
+| `delivery` | object | 是 | 完整收件信息,结构和规则见 6.1 |
+
+只要业务场景、配送等级、取件时间、物品信息、小费或取送地址发生变化,App 都必须重新调用报价接口,不能继续使用变化前的报价。
+
+响应 `data`:
+
+```json
+{
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
+  "pricingId": 1,
+  "startTime": "16:00",
+  "endTime": "18:00",
+  "distanceMeters": 4200,
+  "distanceSource": "ROUTE",
+  "estimatedDurationSeconds": 900,
+  "startingDistance": 3.00,
+  "startingFare": 60,
+  "distance": 1.00,
+  "freight": 10,
+  "urgentRate": 20.00,
+  "minimumUrgentFee": 15,
+  "billableDistance": 1.20,
+  "distanceFee": 12,
+  "baseDeliveryFee": 72,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
+  "currency": "TWD",
+  "pricingVersion": 1
+}
+```
+
+距离规则与现有外卖订单一致:先用路线公里数减去 `startingDistance`;超出不足 `0.5` 公里时 `billableDistance` 为 `0`,达到 `0.5` 但不足 `1` 公里时按 `1` 公里,达到 `1` 公里后按实际超出距离。`distanceFee = roundHalfUp(billableDistance / distance × freight)`,`baseDeliveryFee = startingFare + distanceFee`。普通配送的 `urgentFee=0`;加急配送的 `urgentFee=max(minimumUrgentFee, roundHalfUp(baseDeliveryFee × urgentRate / 100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。小费不参与加急费计算,预计时长也不参与计价。
+
+注意:`baseDeliveryFee` 是已经包含 `distanceFee` 的基础配送费小计。费用合计时不要再次把 `distanceFee` 加入总金额;`distanceFee` 仅用于向用户解释基础配送费中有多少是距离附加费。
+
+立即配送按报价时刻匹配运价,预约配送按 `scheduledPickupStartAt` 的台北时间匹配运价。创建订单必须回传本次报价;后端会重新计算并逐项精确校验,任何变化都不会创建订单,而是返回最新报价供用户重新确认。
+
+### 7.3 创建闪送订单
+
+```http
+POST /system/flashDelivery/orders
+```
+
+创建请求必须在同一个 JSON 对象中完整提交 7.2 的所有报价入参,并增加报价回传、幂等号、PIN、图片和备注字段。`weightRange` 与报价接口的字段名及枚举规则完全一致,不能改传 `totalWeightKg` 或 `packageSize`。
+
+```json
+{
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
+  "packageType": "DOCUMENT",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
+  "pickup": {
+    "name": "王小明",
+    "phone": "0912345678",
+    "address": "台北市信义区市府路1号",
+    "addressDetail": "3楼A室",
+    "city": "台北市",
+    "area": "信义区",
+    "handoffMethod": "请电话联系",
+    "longitude": 121.5645,
+    "latitude": 25.033
+  },
+  "delivery": {
+    "name": "李小华",
+    "phone": "0987654321",
+    "address": "台北市大安区信义路三段56号",
+    "addressDetail": "1楼",
+    "city": "台北市",
+    "area": "大安区",
+    "handoffMethod": "交给本人",
+    "longitude": 121.538,
+    "latitude": 25.0335
+  },
+  "clientRequestId": "FD-20260908-USER1001-0001",
+  "pricingId": 1,
+  "pricingVersion": 1,
+  "quotedBaseDeliveryFee": 72,
+  "quotedDistanceFee": 12,
+  "quotedUrgentFee": 15,
+  "quotedAmount": 92,
+  "pinRequired": true,
+  "senderImageUrls": [
+    "https://api.example.com/profile/upload/sender-1.jpg"
+  ],
+  "userNote": "文件请勿折叠"
+}
+```
+
+其中 `serviceType` 至 `delivery` 的字段定义与 7.2 报价请求表一致;以下是创建订单额外增加的字段:
+
+| 新增字段 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `clientRequestId` | string | 是 | 客户端幂等请求号,去除首尾空格后最长 64 字符 |
+| `pricingId` | integer | 是 | 报价响应的运价配置 ID |
+| `pricingVersion` | integer | 是 | 报价响应的配置版本 |
+| `quotedBaseDeliveryFee` | integer | 是 | 报价响应的基础配送费 |
+| `quotedDistanceFee` | integer | 是 | 报价响应的距离费 |
+| `quotedUrgentFee` | integer | 是 | 报价响应的加急费 |
+| `quotedAmount` | integer | 是 | 报价响应的总金额 |
+| `pinRequired` | boolean | 否 | 不传时默认为 `true` |
+| `payType` | string | 否 | 支付方式:`4`=现金(默认)、`6`=线下转账。闪送款项直达骑手,仅允许线下方式,下单后锁定 |
+| `senderImageUrls` | string[] | 否 | 寄件图片,最多 9 张 |
+| `userNote` | string | 否 | 最长 500 字符 |
+
+同一用户使用相同 `clientRequestId` 重试时,后端返回第一次创建的订单,不会重复创建。一次下单动作生成一个请求号;网络超时重试必须复用原请求号,新下单必须生成新请求号。使用旧请求号但修改请求体,仍会返回旧订单。
+
+成功响应 `data` 为完整详情对象,初始状态为 `WAITING_ACCEPTANCE`。创建响应中的 `amount` 是最终整数订单金额。
+
+若运价版本、路线或费用发生变化,响应 `code=500`、`msg=报价已变化,请重新确认最新费用`,并在 `data` 返回与报价接口相同的完整最新报价。App 必须更新费用明细并要求用户再次确认,不能自动用旧报价重试。
+
+报价变化响应示例:
+
+```json
+{
+  "code": 500,
+  "msg": "报价已变化,请重新确认最新费用",
+  "data": {
+    "serviceType": "HELP_SEND",
+    "deliveryType": "URGENT",
+    "deliveryMode": "SCHEDULED",
+    "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+    "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
+    "pricingId": 2,
+    "startTime": "16:00",
+    "endTime": "18:00",
+    "distanceMeters": 4200,
+    "distanceSource": "ROUTE",
+    "estimatedDurationSeconds": 900,
+    "startingDistance": 3.00,
+    "startingFare": 60,
+    "distance": 1.00,
+    "freight": 10,
+    "urgentRate": 25.00,
+    "minimumUrgentFee": 15,
+    "billableDistance": 1.20,
+    "distanceFee": 12,
+    "baseDeliveryFee": 72,
+    "urgentFee": 18,
+    "tipAmount": 5,
+    "amount": 95,
+    "currency": "TWD",
+    "pricingVersion": 2
+  }
+}
+```
+
+创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
+
+### 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` 处理,传其他值返回业务错误。“我收的”依据订单当前绑定的收件账号;创建时匹配,确认修改取送信息时重新匹配收件电话,查询时不会重新按手机号匹配。
+
+设计原型中的“待接单、配送中、已完成、已取消”筛选页签当前没有对应查询参数。第一版建议只展示“全部”列表;如需页签,只能基于当前页数据本地过滤,且不应依赖它得到准确的分页总数。
+
+### 7.5 查询用户订单详情
+
+```http
+GET /system/flashDelivery/orders/{id}
+```
+
+订单发件人和当前绑定的收件人均可访问。响应 `data` 直接为 App 完整订单字段,包括订单快照、三类图片数组、骑手摘要和可选的交付 PIN,不返回原始状态日志。响应不包含支付状态或支付方式字段,详情页不要展示“已付款”等支付信息。
+
+### 7.6 用户取消订单
+
+```http
+POST /system/flashDelivery/orders/{id}/cancel
+```
+
+请求体:
+
+```json
+{
+  "reason": "行程有变,不需要配送"
+}
+```
+
+- `reason` 必填,去除首尾空格后不能为空,最长 500 字符。
+- 仅订单发件人可以取消;收件人不能取消。
+- 仅 `WAITING_ACCEPTANCE` 和 `ACCEPTED` 状态允许用户取消。
+- `PICKED_UP` 之后需要平台介入,用户端不能直接取消。
+- 设计原型中“骑士接单前可免费取消”的文案比接口口径更严格:接口允许骑手已接单但尚未取件时取消,且当前没有支付,取消不产生任何费用;接入支付后取消规则需重新对齐。
+
+### 7.7 用户确认收货
+
+```http
+POST /system/flashDelivery/orders/{id}/confirmReceipt
+```
+
+无请求体。订单发件人或当前绑定的收件人都可操作;仅 `DELIVERED` 状态可确认,成功后状态变为 `COMPLETED`。
+
+### 7.8 修改发件/收件信息:预报价
+
+```http
+POST /system/flashDelivery/orders/{id}/address/quote
+```
+
+后端方法:`quoteAddress`;请求 DTO:`FlashDeliveryAddressChangeRequest`。
+
+请求体:
+
+```json
+{
+  "orderVersion": 2,
+  "pickup": {
+    "name": "王小明",
+    "phone": "0912345678",
+    "address": "台北市信义区市府路1号",
+    "addressDetail": "3楼A室",
+    "city": "台北市",
+    "area": "信义区",
+    "handoffMethod": "请电话联系",
+    "longitude": 121.5645,
+    "latitude": 25.033
+  },
+  "delivery": {
+    "name": "李小华",
+    "phone": "0922222222",
+    "address": "台北市大安区信义路三段56号",
+    "addressDetail": "交前台",
+    "city": "台北市",
+    "area": "大安区",
+    "handoffMethod": "交前台",
+    "longitude": 121.543,
+    "latitude": 25.0337
+  }
+}
+```
+
+| 字段 | 必填 | 说明 |
+|---|---:|---|
+| `orderVersion` | 是 | 用户详情返回的非负整数订单版本,不是 `pricingVersion` |
+| `pickup` | 是 | 完整发件信息,结构和校验见 6.1 |
+| `delivery` | 是 | 完整收件信息,结构和校验见 6.1 |
+
+- 可以只修改一端,也可以两端一起修改;请求始终提交两端完整信息,未修改的一端原样回传。可选字段不传或传 `null` 表示清空。
+- 仅订单创建人可调用,收件人不能调用。订单必须仍为 `WAITING_ACCEPTANCE` 且未分配骑手。
+- 服务端沿用订单原有计价规则和已有小费,重新计算路线、距离、预计时长和费用。后台调价或跨时段不会切换这笔订单的计价规则;仍待接单的预约单即使已到预约时间也可修改。
+- 此接口不保存地址、费用或状态;App 展示报价供用户确认后,再调用 7.9。
+- 修改的是当前订单,公共地址簿不会同步修改。
+
+成功 `data` 使用 7.2 的完整报价结构,并增加 `orderVersion`。以下为需要展示和回传的关键字段示例(费用以实际响应为准):
+
+```json
+{
+  "orderVersion": 2,
+  "distanceMeters": 4500,
+  "distanceSource": "ROUTE",
+  "estimatedDurationSeconds": 900,
+  "baseDeliveryFee": 113,
+  "distanceFee": 23,
+  "urgentFee": 0,
+  "tipAmount": 10,
+  "amount": 123,
+  "currency": "TWD"
+}
+```
+
+`distanceFee` 已包含在 `baseDeliveryFee` 中,总金额为 `baseDeliveryFee + urgentFee + tipAmount`。地址编辑使用本接口报价,不使用 7.2 的新订单报价接口。
+
+### 7.9 修改发件/收件信息:确认保存
+
+```http
+POST /system/flashDelivery/orders/address
+```
+
+后端方法:`updateAddress`;请求 DTO:`FlashDeliveryAddressConfirmRequest`。
+
+订单 ID 不放 URL,必须在请求体中提交 `orderId`(用户订单详情的 `id`,正整数)。同时提交与 7.8 同一次报价对应的完整 `pickup`、`delivery` 和 `orderVersion`,并增加以下必填字段:
+
+| 确认字段 | 类型 | 从地址报价响应取值 |
+|---|---|---|
+| `orderId` | integer | 用户订单详情的 `id`,不是订单号 |
+| `quotedDistanceMeters` | integer | `distanceMeters` |
+| `quotedBaseDeliveryFee` | integer | `baseDeliveryFee` |
+| `quotedDistanceFee` | integer | `distanceFee` |
+| `quotedUrgentFee` | integer | `urgentFee`;普通配送也必须传 0 |
+| `quotedAmount` | integer | `amount` |
+
+例如在上节完整请求体中增加 `orderId=101`、`quotedDistanceMeters=4500`、`quotedBaseDeliveryFee=113`、`quotedDistanceFee=23`、`quotedUrgentFee=0`、`quotedAmount=123`。不要在确认报价后更换地址;更换地址后应重新预报价。
+
+保存规则:
+
+- 服务端再次计算路线及费用,逐项核对上述距离与费用。任一不一致或漏传时,返回 `code=500`、国际化“报价已变化,请重新确认”及 `data` 中的最新完整报价,订单保持原值。App 应重新展示,并等待用户确认后再提交。
+- 只有订单归属、待接单状态、骑手为空及 `orderVersion` 同时匹配,才能原子保存。报价后被骑手接走、被取消或发生其他编辑时,保存会失败。
+- 成功时 `data` 直接返回完整订单详情,含更新后的两端信息、路线、费用和递增后的 `orderVersion`;订单 ID、编号、物品、原计价规则、预约信息和已有小费保持不变。
+- 收件电话重新匹配当前可绑定账号;无法唯一匹配时清空原收件人绑定。原账号不再是当前收件人时,随即失去“我收的”、详情及签收权限;创建人的权限不受影响。
+
+### 7.10 追加骑手小费
+
+```http
+POST /system/flashDelivery/orders/tip
+```
+
+后端方法:`addTip`;请求 DTO:`FlashDeliveryTipAddRequest`。
+
+请求体:
+
+```json
+{
+  "orderId": 101,
+  "orderVersion": 2,
+  "additionalTipAmount": 20
+}
+```
+
+| 字段 | 必填 | 规则 |
+|---|---:|---|
+| `orderVersion` | 是 | 用户详情返回的最新订单版本 |
+| `orderId` | 是 | 用户订单详情的 `id`,正整数;不放 URL |
+| `additionalTipAmount` | 是 | 本次追加的正整数 TWD;小数、零、负数、空值及超出金额范围均拒绝 |
+
+- 仅订单创建人能在待接单、无骑手时追加。金额是本次增量,例如原小费为 10,追加 20 后小费变为 30;不是把小费设为 20。
+- 仅增加 `tipAmount` 和 `amount`,基础配送费、加急费、路线和距离不变。此接口不发起支付或扣款。
+- 成功 `data` 直接返回更新后的完整订单详情,含递增后的 `orderVersion`。App 必须用响应刷新小费、总金额和版本。
+- 同一请求成功后,重复提交旧版本会被拒绝,不会重复加小费。网络超时应先刷新详情核对结果,不能自动换新版本再次追加。
+- 如果地址预报价后追加了小费,先前地址确认使用的版本已过期,需刷新详情并重新获取地址报价。
+
+新增接口通用失败处理:
+
+- 缺失或负数版本:提示提供有效的订单版本,重新读取详情。
+- 旧版本或保存时订单发生变化:提示状态变化,刷新详情后由用户决定后续操作。
+- 已接单、已取消或已有骑手:关闭地址修改和追加小费入口。
+- 收件人及其他用户无权调用:按订单不存在或无权访问处理。
+
+## 8. 骑手端接口
+
+所有接口均要求骑手登录 `token`,且账号 `userType` 必须为 `2`。
+
+接单使用共享 Redis 锁 `lock:delivery:rider:{riderId}` 串行执行跨外卖、闪送的资格检查和订单条件更新,并在事务完成后释放。该锁只覆盖接单事务,不会一直持有到配送完成;持续独占由每次接新单时查询骑手进行中的订单保证,Redis 锁用于防止两个并发接单请求同时绕过查询。
+
+`deliveryType=URGENT` 的急送要求骑手没有任何进行中的外卖或闪送;骑手已有进行中的急送时,也不能再接普通闪送或外卖。普通闪送不会阻止骑手承接外卖,外卖也不会阻止骑手承接普通闪送。Redis 不可用或 3 秒内未取得锁时采用失败关闭策略,本次接单直接失败,不更新订单。
+
+### 8.1 查询骑手订单列表
+
+```http
+GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0
+```
+
+查询参数:
+
+| 参数 | 必填 | 说明 |
+|---|---:|---|
+| `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
+| `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
+| `tab` | 否 | 默认 `newTask`;可选 `newTask`、`toPickup`、`delivering`、`completed` 或 `cancelled` |
+| `longitude` | 否 | 骑手当前位置经度;`newTask` 传入时用于附近范围、取件点距离和排序,范围 `-180` 至 `180` |
+| `latitude` | 否 | 骑手当前位置纬度;`newTask` 传入时用于附近范围、取件点距离和排序,范围 `-90` 至 `90` |
+
+页签映射:
+
+| `tab` | 对应状态 | 查询范围 |
+|---|---|---|
+| `newTask` | `WAITING_ACCEPTANCE` | 当前可抢任务 |
+| `toPickup` | `ACCEPTED` | 当前骑手本人任务 |
+| `delivering` | `PICKED_UP` | 当前骑手本人任务 |
+| `completed` | `DELIVERED`、`COMPLETED` | 当前骑手本人任务 |
+| `cancelled` | `CANCELLED` | 当前骑手本人任务 |
+
+除 `newTask` 外只查询当前骑手本人任务。闪送没有退款流程,不提供外卖列表中的 `refund` 页签。
+
+`newTask` 的 `longitude` 和 `latitude` 仅用于附近范围筛选、计算取件点距离和排序;其他页签忽略坐标。列表按页签和骑手范围完成服务端过滤后再分页,不由 App 拉取完整集合后过滤。
+
+`newTask` 分页响应的 `data` 除 `records`、`total`、`current`、`size` 外,还返回 `nearbyTaskCount` 和 `highestOrderAmount`。不返回尖峰倍率或骑手收入字段。`amount` 是服务端计算的订单报价金额,当前不会发起支付或扣款,页面文案不得表述为“预估收益”或“收益”;`nearbyTaskCount` 是附近待抢订单数,不是上线骑手数。
+
+所有页签的 `data.records` 使用骑手列表摘要字段:
+
+```json
+{
+  "id": 101,
+  "orderNo": "FD1234567890",
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "status": "WAITING_ACCEPTANCE",
+  "packageType": "DOCUMENT",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "deliveryMode": "NOW",
+  "scheduledPickupStartAt": null,
+  "scheduledPickupEndAt": null,
+  "pinRequired": true,
+  "pickupAddress": "台北市信义区市府路1号",
+  "pickupDetailAddress": "3楼A室",
+  "deliveryAddress": "台北市大安区信义路三段56号",
+  "deliveryDetailAddress": "1楼",
+  "pickupDistanceMeters": 420,
+  "distanceMeters": 4200,
+  "estimatedDurationSeconds": 900,
+  "baseDeliveryFee": 72,
+  "distanceFee": 12,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
+  "currency": "TWD",
+  "createTime": "2026-09-01T09:00:00+08:00"
+}
+```
+
+接单前列表可展示完整的取件、送达文字地址(包括详细地址),但不得返回联系人、电话、实际 PIN、精确坐标、用户备注、图片凭证、状态日志、用户 ID、幂等号或计价审计字段。列表中的文字地址不能作为精确坐标使用。
+
+### 8.2 查询骑手订单详情
+
+```http
+GET /system/flashDelivery/rider/orders/{id}
+```
+
+响应 `data` 始终直接返回订单字段,顶层结构保持稳定,不使用 `{order,images,logs}` 包装,也不要求 App 判断 `data.order`。接单前隐藏联系人、电话、实际 PIN、精确坐标和履约图片;联系人姓名与电话一样都不返回,列表和详情不要按设计原型渲染“·联系人姓名”。当前骑手接单后,详情接口才补齐其有权限查看的完整履约信息。
+
+### 8.3 抢单
+
+```http
+POST /system/flashDelivery/rider/orders/{id}/accept
+```
+
+无请求体。骑手账号必须已开通闪送配送。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。违反急送独占规则时返回统一的独占冲突提示,App 应保留在列表页并刷新可接任务。
+
+### 8.4 确认取件
+
+```http
+POST /system/flashDelivery/rider/orders/{id}/pickup
+```
+
+请求体:
+
+```json
+{
+  "imageUrls": [
+    "https://api.example.com/profile/upload/pickup-1.jpg"
+  ]
+}
+```
+
+仅订单骑手可在 `ACCEPTED` 状态调用。图片至少 1 张、最多 9 张;成功后状态变为 `PICKED_UP`。
+
+### 8.5 确认送达
+
+```http
+POST /system/flashDelivery/rider/orders/{id}/deliver
+```
+
+启用 PIN 的订单:
+
+```json
+{
+  "imageUrls": [
+    "https://api.example.com/profile/upload/delivery-1.jpg"
+  ],
+  "pinCode": "4821"
+}
+```
+
+未启用 PIN 的订单可不传 `pinCode`:
+
+```json
+{
+  "imageUrls": [
+    "https://api.example.com/profile/upload/delivery-1.jpg"
+  ]
+}
+```
+
+仅订单骑手可在 `PICKED_UP` 状态调用。图片至少 1 张、最多 9 张。PIN 校验失败时不会保存送达图片,也不会改变订单状态;成功后状态变为 `DELIVERED`。
+
+## 9. 共享地址簿接口
+
+地址簿由闪送和现有收货业务共用。以下接口都要求用户登录 `token`,并且只能操作当前用户自己的地址。
+
+### 9.1 查询地址列表
+
+```http
+GET /system/address/getaddress?keyword=王小明
+```
+
+`keyword` 可选,同时搜索姓名、电话、主地址和详细地址。置顶地址优先,响应 `data` 为地址数组。
+
+### 9.2 查询地址详情
+
+```http
+GET /system/address/getaddressxq?id={addressId}
+```
+
+响应 `data` 为地址对象。
+
+### 9.3 新增或修改地址
+
+```http
+POST /system/address/address
+```
+
+新增地址不传 `id`;修改地址传本人地址的 `id`:
+
+```json
+{
+  "id": null,
+  "name": "王小明",
+  "phone": "0912345678",
+  "address": "台北市信义区市府路1号",
+  "addressDetail": "3楼A室",
+  "longitude": 121.5645,
+  "latitude": 25.033,
+  "country": "TW",
+  "province": "台北市",
+  "city": "台北市",
+  "area": "信义区",
+  "annexes": null
+}
+```
+
+| 字段 | 必填 | 规则 |
+|---|---:|---|
+| `name` | 是 | 最长 64 字符 |
+| `phone` | 是 | 最长 32 字符 |
+| `address` | 是 | 最长 255 字符 |
+| `addressDetail` | 否 | 最长 255 字符 |
+| `longitude` | 是 | `-180` 至 `180` |
+| `latitude` | 是 | `-90` 至 `90` |
+| `country`、`province`、`city`、`area` | 否 | 各最长 64 字符 |
+| `annexes` | 否 | 最长 1000 字符 |
+
+后端忽略客户端身份字段,地址所有者始终取自 `token`。保存成功后 `data` 返回保存后的地址对象。
+
+地址簿对象不含 `handoffMethod`。把地址用于闪送报价或下单时,需要映射联系人、地址和坐标,并按本次订单另行填写交接方式。
+
+### 9.4 删除地址
+
+```http
+DELETE /system/address/{id}
+```
+
+无请求体,仅可删除本人地址。
+
+### 9.5 置顶地址
+
+```http
+POST /system/address/{id}/top
+```
+
+无请求体。置顶只影响当前用户地址簿的排序。
+
+## 10. 常见业务错误与处理建议
+
+| `msg` 含义 | 常见原因 | App 处理建议 |
+|---|---|---|
+| token 已过期 | 登录态失效 | 清理登录态并重新登录 |
+| 对应取件时段暂无可用运价 | 立即时刻或预约开始时刻没有匹配统一运价 | 重新选择取件时间或稍后重试 |
+| 报价已变化,请重新确认最新费用 | 创建订单报价不一致,或修改地址的确认距离/费用与复算结果不一致 | 用响应 `data` 刷新费用并让用户再次确认 |
+| 物品数量、重量范围或规格信息不正确 | 数量非正整数、`weightRange` 缺失或不属于四个合法枚举、规格过长 | 返回物品信息页修正 |
+| 取件地址和收件地址不能相同 | 地址文本相同或经纬度完全相同 | 要求用户修改地址 |
+| 配送距离不能超过 40 公里 | 服务端路线距离超限 | 提示当前路线不可下单 |
+| 预约取件时段无效 | 时间已过、超过三天或不是 30 分钟 | 重新选择预约时段 |
+| 订单已被其他骑手接走 | 并发抢单失败 | 刷新待抢列表 |
+| 骑手存在进行中的独占配送任务 | 当前订单或骑手已有急送冲突 | 保留当前页并刷新可接任务 |
+| 系统繁忙,请稍后重试 | Redis 不可用或 3 秒内未取得骑手锁 | 不重复提交,稍后刷新并重试 |
+| 当前订单状态不允许此操作 | 页面状态已过期或重复操作 | 重新拉取订单详情 |
+| 仅待接单且未分配骑手的订单可以修改 | 修改地址/追加小费时已接单、已取消或已有骑手 | 刷新详情并关闭编辑入口 |
+| 请提供有效的订单版本 | 缺失或负数 `orderVersion` | 读取用户详情取得版本 |
+| 订单状态已变化,请刷新后重试 | 编辑使用旧版本,或最终保存时发生接单/取消/其他编辑 | 刷新详情;地址重新报价;小费先核对是否已追加 |
+| 追加小费必须为正整数且不能超出金额范围 | 小数、零、负数、空值或金额溢出 | 修正本次追加金额 |
+| 交付 PIN 不正确 | 骑手提交 PIN 错误 | 保留页面并重新输入 PIN |
+| 闪送订单不存在或无权访问 | ID 不存在或不属于当前账号 | 返回列表并刷新数据 |
+| 凭证图片地址无效 | URL 不是完整 HTTP(S) 地址 | 检查上传结果并补全资源域名 |
+
+## 11. 接入验收清单
+
+- 首页只展示 `/home` 返回的当前时段可用服务,不在 App 中维护启停状态或默认价格。
+- 报价和创建订单都提交 `serviceType`、`deliveryType`、配送方式、`packageType`、`quantity`、`weightRange`、规格、骑手小费及取送坐标。
+- 创建订单原样回传报价的配置 ID、版本、基础配送费、距离费、加急费和总金额;报价变化时展示最新明细并重新确认。
+- 金额按整数 TWD 展示;`baseDeliveryFee` 已包含 `distanceFee`,距离费只作为基础配送费的明细展示,总金额只按 `baseDeliveryFee + urgentFee + tipAmount` 计算;不显示支付成功、骑手收入、时长费或尖峰费用。
+- 创建订单具备稳定的 `clientRequestId` 重试策略。
+- 待接单详情的“改地址电话”“加小费”仅向订单创建人开放;三个新增接口均携带最新 `orderVersion`。
+- 修改地址先调用 7.8 展示新路线和费用,用户确认后调用 7.9;报价变化必须再次确认,不提前更新本地订单。
+- 追加小费传本次增量,成功后同步展示返回的小费及总额;超时先查询,不自动更换版本重复追加。
+- 报价后接单、取消或追加小费导致版本过期时,旧地址确认必须失败;编辑成功后用返回的版本替换本地版本。
+- 预约时间满足未来三天内、固定 30 分钟的约束。
+- App 使用响应体 `code` 判断成功或失败。
+- 分页列表从 `data.records` 和 `data.total` 取值。
+- 图片先上传,再向闪送接口提交完整 HTTP(S) URL。
+- “我发的”只在待接单或已接单状态显示取消入口;“我发的”和“我收的”都只在已送达状态显示确认收货入口。
+- 骑手抢单前可展示完整文字地址但不使用坐标导航;抢单成功后再读取联系人、电话、精确坐标和履约图片。
+- 骑手严格按 `ACCEPTED -> PICKED_UP -> DELIVERED` 顺序操作。
+- 启用 PIN 时,骑手送达必须提交用户端详情显示的四位 PIN。
+- 对抢单冲突、状态变化和重复点击,均以刷新后的服务端订单状态为准。
+- 用户订单列表使用 `role=sender/receiver` 切换“我发的”和“我收的”;状态页签如需展示,仅基于当前页数据本地过滤。
+- “我收的”按订单当前绑定的账号展示;创建或确认修改取送信息时匹配收件电话,清空或更换绑定后旧收件人不再有访问权;查询时不动态认领历史订单。
+- 发布成功页显示“发布后等待附近骑手接单”,不显示虚构的骑手数量或预计接单时长。
+- 骑手端金额文案统一为“订单金额”,不使用“收益”;联系骑手/用户通过 IM,不提供通话。

+ 501 - 0
docs/merchant-subaccount-api.md

@@ -0,0 +1,501 @@
+# 商家门店管理账号 API 接口文档
+
+> 更新时间:2026-08-31
+> 适用范围:商家端门店分管账号管理、分管账号登录,以及平台端查看和强制启停分管账号。
+> 本文档以当前后端实现为准。
+
+## 1. 公共约定
+
+### 1.1 账号类型
+
+| `userType` | 含义 |
+|---|---|
+| `1` | 商家主账号 |
+| `5` | 商家门店分管账号 |
+
+只有 `userType = 1` 的商家主账号可以新增、编辑、重置密码和启停分管账号。分管账号不能调用账号管理接口。
+
+### 1.2 请求格式
+
+- 商家端接口使用 `Content-Type: application/json`。
+- 除登录接口外,请求头必须携带商家端登录返回的 `token`。
+- `token` 直接放在请求头中,不添加 `Bearer` 前缀。
+
+```http
+token: 登录接口返回的 token
+Content-Type: application/json
+```
+
+平台端接口沿用若依后台现有认证方式。
+
+### 1.3 通用响应
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {}
+}
+```
+
+没有返回数据的成功响应不包含 `data`:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功"
+}
+```
+
+业务失败响应:
+
+```json
+{
+  "code": 500,
+  "msg": "具体错误信息"
+}
+```
+
+前端应以响应体中的 `code === 200` 判断业务是否成功,不要只依赖 HTTP 状态码。
+
+### 1.4 分管账号返回对象
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `userId` | `number` | 分管账号用户 ID |
+| `name` | `string` | 姓名 |
+| `phone` | `string` | 登录手机号,同时也是登录账号 |
+| `merchantOwnerId` | `number` | 所属商家主账号用户 ID |
+| `ownerEnabled` | `boolean` | 主账号控制状态,`true` 为启用 |
+| `platformEnabled` | `boolean` | 平台控制状态,`true` 为启用 |
+| `online` | `boolean` | 当前是否存在有效商家端登录会话 |
+| `lastLoginAt` | `string \| null` | 最后登录时间,未登录过时可能为 `null` |
+| `createdAt` | `string \| null` | 创建时间 |
+| `stores` | `array` | 当前负责的门店列表 |
+| `stores[].storeId` | `number` | 门店 ID |
+| `stores[].storeName` | `string` | 门店名称 |
+
+账号实际可用条件:
+
+```text
+ownerEnabled === true && platformEnabled === true
+```
+
+## 2. 商家端接口汇总
+
+基础路径:`/merchant/subaccounts`
+
+| 功能 | 方法 | 地址 | 调用方 |
+|---|---|---|---|
+| 查询分管账号列表 | `GET` | `/merchant/subaccounts` | 商家主账号 |
+| 创建分管账号 | `POST` | `/merchant/subaccounts` | 商家主账号 |
+| 编辑姓名和负责门店 | `PUT` | `/merchant/subaccounts/{subaccountUserId}` | 所属商家主账号 |
+| 重置密码 | `PUT` | `/merchant/subaccounts/{subaccountUserId}/password` | 所属商家主账号 |
+| 启用或停用账号 | `PUT` | `/merchant/subaccounts/{subaccountUserId}/status` | 所属商家主账号 |
+
+## 3. 获取可分配门店
+
+创建或编辑分管账号前,可调用现有“我的门店”接口取得门店选择数据。
+
+### `GET /chanting/store/getmystorelist`
+
+请求头:
+
+```http
+token: 商家主账号 token
+```
+
+请求参数:无。
+
+成功响应示例:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": [
+    {
+      "id": 101,
+      "posName": "台北一店"
+    },
+    {
+      "id": 102,
+      "posName": "台北二店"
+    }
+  ]
+}
+```
+
+该接口实际会返回完整门店对象。账号管理页面至少使用以下两个字段:
+
+| 门店字段 | 用途 |
+|---|---|
+| `id` | 提交到账号接口的 `storeIds` |
+| `posName` | 门店名称展示 |
+
+## 4. 查询分管账号列表
+
+### `GET /merchant/subaccounts`
+
+仅商家主账号可调用。接口不分页,返回当前主账号名下全部未删除的分管账号。
+
+请求头:
+
+```http
+token: 商家主账号 token
+```
+
+请求参数:无。
+
+成功响应示例:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": [
+    {
+      "userId": 501,
+      "name": "张三",
+      "phone": "+886900000001",
+      "merchantOwnerId": 10001,
+      "ownerEnabled": true,
+      "platformEnabled": true,
+      "online": true,
+      "lastLoginAt": "2026-08-28T10:30:00",
+      "createdAt": "2026-08-20T09:00:00",
+      "stores": [
+        {
+          "storeId": 101,
+          "storeName": "台北一店"
+        },
+        {
+          "storeId": 102,
+          "storeName": "台北二店"
+        }
+      ]
+    }
+  ]
+}
+```
+
+## 5. 创建分管账号
+
+### `POST /merchant/subaccounts`
+
+仅商家主账号可调用。
+
+请求头:
+
+```http
+token: 商家主账号 token
+Content-Type: application/json
+```
+
+请求体:
+
+```json
+{
+  "phone": "+886900000001",
+  "name": "张三",
+  "password": "前端 RSA 加密后的密码",
+  "storeIds": [101, 102]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `phone` | `string` | 是 | 登录手机号,同时作为登录账号;服务端会移除其中的空白字符;最长 32 个字符;全局不可重复 |
+| `name` | `string` | 是 | 分管账号姓名,不能只包含空白字符 |
+| `password` | `string` | 是 | 使用商家端现有登录密码相同的 RSA 加密方式提交 |
+| `storeIds` | `number[]` | 是 | 至少一个门店 ID;所有门店必须属于当前主账号;重复 ID 会被去重 |
+
+成功响应的 `data` 为完整的分管账号对象:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "userId": 501,
+    "name": "张三",
+    "phone": "+886900000001",
+    "merchantOwnerId": 10001,
+    "ownerEnabled": true,
+    "platformEnabled": true,
+    "online": false,
+    "lastLoginAt": null,
+    "createdAt": "2026-08-31T11:00:00",
+    "stores": [
+      {
+        "storeId": 101,
+        "storeName": "台北一店"
+      },
+      {
+        "storeId": 102,
+        "storeName": "台北二店"
+      }
+    ]
+  }
+}
+```
+
+## 6. 编辑姓名和负责门店
+
+### `PUT /merchant/subaccounts/{subaccountUserId}`
+
+只能修改当前主账号所属的分管账号。
+
+路径参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `subaccountUserId` | `number` | 是 | 分管账号的 `userId` |
+
+请求体:
+
+```json
+{
+  "name": "张三",
+  "storeIds": [102, 103]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `name` | `string` | 是 | 修改后的姓名 |
+| `storeIds` | `number[]` | 是 | 修改后的完整门店 ID 集合,至少一个;不是增量添加 |
+
+成功响应的 `data` 为修改后的完整分管账号对象。
+
+注意:当前接口不支持修改手机号。`storeIds` 会整体覆盖原门店授权,提交前必须传入希望保留的全部门店 ID。
+
+## 7. 重置密码
+
+### `PUT /merchant/subaccounts/{subaccountUserId}/password`
+
+只能重置当前主账号所属分管账号的密码。
+
+请求体:
+
+```json
+{
+  "password": "前端 RSA 加密后的新密码"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `password` | `string` | 是 | 使用商家端现有登录密码相同的 RSA 加密方式提交 |
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功"
+}
+```
+
+密码重置成功后,后端会立即撤销该分管账号已有的 App 和 PC 会话,账号需要使用新密码重新登录。
+
+## 8. 启用或停用账号
+
+### `PUT /merchant/subaccounts/{subaccountUserId}/status`
+
+该接口只修改主账号控制状态,即返回对象中的 `ownerEnabled`。
+
+请求体:
+
+```json
+{
+  "enabled": false
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `enabled` | `boolean` | 是 | `true` 启用,`false` 停用 |
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功"
+}
+```
+
+- 停用后,后端会立即撤销该分管账号已有的 App 和 PC 会话。
+- 主账号重新启用账号时,只能把 `ownerEnabled` 改为 `true`,不能覆盖平台的 `platformEnabled` 状态。
+- 如果 `platformEnabled === false`,即使主账号已启用,该账号仍然不可登录和使用。
+
+## 9. 分管账号登录
+
+分管账号复用现有商家登录接口,不使用单独的登录地址。
+
+### `POST /infouser/user/shanglodeing`
+
+该接口不需要 `token`。
+
+请求体示例:
+
+```json
+{
+  "userName": "+886900000001",
+  "password": "前端 RSA 加密后的密码",
+  "cid": "推送客户端标识",
+  "cidType": "ios",
+  "deviceToken": "设备推送 token",
+  "voIPToken": "iOS VoIP token"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `userName` | `string` | 是 | 创建分管账号时提交的手机号 |
+| `password` | `string` | 是 | RSA 加密后的密码 |
+| `cid` | `string` | 否 | 当前商家端使用的推送客户端标识 |
+| `cidType` | `string` | 否 | 设备类型,例如 `ios`、`android` |
+| `deviceToken` | `string` | 否 | 普通设备推送 token |
+| `voIPToken` | `string` | 否 | iOS VoIP 推送 token |
+
+分管账号登录成功响应示例:
+
+```json
+{
+  "code": 200,
+  "msg": "登录成功",
+  "token": "登录 token",
+  "data": {
+    "userId": 501,
+    "userName": "+886900000001",
+    "nickName": "张三",
+    "userType": "5",
+    "storeId": null,
+    "merchantOwnerId": 10001,
+    "status": "0",
+    "subaccountStatus": "0",
+    "lastLoginAt": "2026-08-31T11:30:00"
+  }
+}
+```
+
+前端可使用 `data.userType === "5"` 识别分管账号。分管账号只能访问其被授权的门店;门店权限由后端校验,前端不能通过修改门店 ID 扩大权限。
+
+登录时以下任一情况都会失败:
+
+- 分管账号被主账号停用;
+- 分管账号被平台停用;
+- 所属商家主账号不可用;
+- 账号或密码错误。
+
+## 10. 退出登录
+
+### `POST /infouser/user/merchantLogout`
+
+请求头:
+
+```http
+token: 当前商家端 token
+```
+
+请求体:无。
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功"
+}
+```
+
+前端应先调用退出接口,再清除本地 token。
+
+## 11. 平台端接口
+
+平台端只允许查看分管账号以及强制启用或停用。平台不能新增分管账号、重置密码或修改门店授权。
+
+### 11.1 查询指定商家的分管账号
+
+#### `GET /infouser/merchant-subaccounts?merchantUserId={id}`
+
+权限标识:`infouser:user:list`
+
+查询参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `merchantUserId` | `number` | 是 | 商家主账号用户 ID,必须是 `userType = 1` 的有效账号 |
+
+成功响应的 `data` 与商家端“查询分管账号列表”一致。
+
+### 11.2 平台强制启用或停用
+
+#### `PUT /infouser/merchant-subaccounts/{subaccountUserId}/platform-status`
+
+权限标识:`infouser:user:edit`
+
+请求体:
+
+```json
+{
+  "enabled": false
+}
+```
+
+该接口只修改返回对象中的 `platformEnabled`。平台停用账号后会立即撤销该账号已有的 App 和 PC 会话。
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功"
+}
+```
+
+## 12. 常见业务错误
+
+错误消息会根据服务端当前语言返回对应的简体中文、繁体中文、英文或越南语。以下为简体中文示例:
+
+| `msg` 示例 | 触发情况 |
+|---|---|
+| `仅商家主账号可执行此操作` | 分管账号或非商家主账号调用账号管理接口 |
+| `商家主账号不存在` | 平台查询的 `merchantUserId` 不是有效商家主账号 |
+| `请求数据不能为空` | 创建或编辑请求体为空 |
+| `分管账号姓名不能为空` | `name` 为空或只有空白字符 |
+| `分管账号手机号不能为空` | `phone` 为空 |
+| `分管账号手机号格式无效` | 去除空白后的手机号长度超过 32 个字符 |
+| `该手机号已被使用` | 手机号或同名登录账号已存在 |
+| `分管账号密码不能为空` | `password` 为空或只有空白字符 |
+| `请至少选择一个负责店铺` | `storeIds` 为空数组或未提交 |
+| `无权访问该店铺` | `storeIds` 包含不属于当前主账号的门店 |
+| `分管账号状态不能为空` | `enabled` 未提交或为 `null` |
+| `分管账号不存在` | 目标 ID 不是有效分管账号 |
+| `无权管理该分管账号` | 主账号尝试修改其他商家的分管账号 |
+| `商家账号不可用` | 账号被平台停用 |
+| `所属商家主账号不可用` | 分管账号所属主账号不可用 |
+
+## 13. 前端调用顺序
+
+```text
+进入账号管理页
+  -> GET /chanting/store/getmystorelist
+  -> GET /merchant/subaccounts
+
+创建账号
+  -> POST /merchant/subaccounts
+  -> 成功后使用响应 data 更新列表,或重新查询列表
+
+编辑账号
+  -> PUT /merchant/subaccounts/{userId}
+  -> 成功后使用响应 data 更新列表
+
+重置密码或启停账号
+  -> 调用对应 PUT 接口
+  -> 成功后重新查询列表,刷新 online 和状态字段
+```

+ 409 - 0
docs/superpowers/plans/2026-09-07-backend-thai-i18n.md

@@ -0,0 +1,409 @@
+# 后端泰语提示支持实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为所有后端系统提示增加完整泰语资源和安全的泰语 Locale 解析,同时保持台湾繁体中文为默认语言。
+
+**Architecture:** Spring MessageSource 继续从 `i18n/messages` 读取资源,新增 `messages_th_TH.properties` 提供完整泰语值;同步请求继续由 `lang` 参数切换 Locale,异步消息通过修复后的 `LocaleUtils.parseLocale(String)` 解析用户语言。业务数据的数字语言映射保持不变,避免把尚不存在的泰语商品或门店数据纳入本阶段。
+
+**Tech Stack:** Java 21、Spring Boot MessageSource、Spring MVC LocaleResolver、JUnit 5、Maven、UTF-8 properties。
+
+## Global Constraints
+
+- 默认 Locale 必须继续是台湾繁体中文 `zh_TW`。
+- 泰语标准代码为 `th_TH`,并兼容 `th-TH` 和 `th`。
+- `messages_th_TH.properties` 必须与现有四个本地化语言包拥有完全一致且非空的消息 key。
+- 占位符集合必须逐 key 保持一致,例如 `{0}`、`{1}` 不得遗漏。
+- 不修改 `toLangCode()`、`getUserLanguageCode()`、数据库字段或业务数据语言映射。
+- 不修改平台管理端、商家管理端和 App 前端语言资源。
+- 现有工作区中的闪送请求 DTO/Controller 注释修改属于另一项工作,不得在泰语功能提交中丢失或误暂存。
+- 按用户要求,先完成全部生产代码和测试源码,再统一运行测试与构建;中途不执行 Maven。
+- Git 提交标题和正文使用中文,不添加英文 Conventional Commits 前缀。
+
+---
+
+### Task 1: 定义 Locale 解析契约并修复解析器
+
+**Files:**
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java`
+- Modify: `ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java`
+
+**Interfaces:**
+- Consumes: Redis 中已保存的字符串语言代码,以及直接调用 `LocaleUtils.parseLocale(String)` 的代码。
+- Produces: `public static Locale parseLocale(String language)`;支持白名单语言代码并对空值、未知值回退 `zh_TW`。
+
+- [ ] **Step 1: 编写 Locale 白名单解析测试源码**
+
+创建参数化测试,锁定所有受支持别名与默认回退:
+
+```java
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LocaleUtilsTest {
+    @ParameterizedTest
+    @CsvSource({
+            "zh_CN,zh,CN", "zh-CN,zh,CN", "zh,zh,CN",
+            "zh_TW,zh,TW", "zh-TW,zh,TW", "tw,zh,TW",
+            "en_US,en,US", "en-US,en,US", "en,en,US",
+            "vi_VN,vi,VN", "vi-VN,vi,VN", "vi,vi,VN",
+            "th_TH,th,TH", "th-TH,th,TH", "th,th,TH"
+    })
+    void parsesSupportedLanguageAliases(String input, String language, String country) {
+        assertEquals(new Locale(language, country), LocaleUtils.parseLocale(input));
+    }
+
+    @Test
+    void fallsBackToTraditionalChineseForMissingOrUnknownLanguage() {
+        Locale expected = new Locale("zh", "TW");
+        assertEquals(expected, LocaleUtils.parseLocale(null));
+        assertEquals(expected, LocaleUtils.parseLocale("  "));
+        assertEquals(expected, LocaleUtils.parseLocale("fr-FR"));
+    }
+}
+```
+
+- [ ] **Step 2: 实现白名单 Locale 解析**
+
+保留 `DEFAULT_LOCALE`,新增明确的 Locale 常量并只修改 `parseLocale`。先 `trim()`,再把 `-` 转为 `_` 并使用 `Locale.ROOT` 转小写,最后使用 switch 返回白名单 Locale:
+
+```java
+private static final Locale SIMPLIFIED_CHINESE = new Locale("zh", "CN");
+private static final Locale ENGLISH = new Locale("en", "US");
+private static final Locale VIETNAMESE = new Locale("vi", "VN");
+private static final Locale THAI = new Locale("th", "TH");
+
+public static Locale parseLocale(String language)
+{
+    if (StringUtils.isEmpty(language) || language.trim().isEmpty())
+    {
+        return DEFAULT_LOCALE;
+    }
+    String normalized = language.trim().replace('-', '_').toLowerCase(Locale.ROOT);
+    return switch (normalized)
+    {
+        case "zh", "zh_cn" -> SIMPLIFIED_CHINESE;
+        case "zh_tw", "tw" -> DEFAULT_LOCALE;
+        case "en", "en_us" -> ENGLISH;
+        case "vi", "vi_vn" -> VIETNAMESE;
+        case "th", "th_th" -> THAI;
+        default -> DEFAULT_LOCALE;
+    };
+}
+```
+
+不得修改 `toLangCode()` 和 `getUserLanguageCode()`。
+
+- [ ] **Step 3: 静态检查本任务差异,暂不运行 Maven**
+
+运行:
+
+```powershell
+git diff --check -- ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
+```
+
+预期:无错误输出。按全局约束,将测试执行延后到 Task 4。
+
+- [ ] **Step 4: 提交 Locale 解析改动**
+
+```powershell
+git add -- ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "支持后端泰语语言解析" -m "兼容 th_TH、th-TH 和 th,并为现有受支持语言建立白名单解析;空值和未知语言继续回退台湾繁体。"
+```
+
+### Task 2: 新增完整泰语消息资源和语言包契约
+
+**Files:**
+- Create: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+**Interfaces:**
+- Consumes: `ResourceBundleMessageSource` 的 basename `i18n/messages` 和现有 319 个本地化消息 key。
+- Produces: 完整的泰语资源包 `messages_th_TH.properties`,由 `Locale("th", "TH")` 自动加载。
+
+- [ ] **Step 1: 编写语言包完整性和占位符契约测试源码**
+
+创建 `BackendI18nBundleContractTest`,使用 UTF-8 Reader 加载资源;以繁体中文包作为 key 与占位符参考,逐一比较英文、简中、越南语和泰语:
+
+```java
+package com.ruoyi.common.utils;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.support.ResourceBundleMessageSource;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class BackendI18nBundleContractTest {
+    private static final List<String> LOCALIZED_BUNDLES = List.of(
+            "messages_zh_CN.properties", "messages_zh_TW.properties",
+            "messages_en_US.properties", "messages_vi.properties",
+            "messages_th_TH.properties");
+    private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\d+}");
+
+    @Test
+    void everyLocalizedBundleHasTheSameNonBlankKeysAndPlaceholders() throws Exception {
+        Properties reference = load("messages_zh_TW.properties");
+        for (String bundle : LOCALIZED_BUNDLES) {
+            Properties candidate = load(bundle);
+            assertEquals(reference.stringPropertyNames(), candidate.stringPropertyNames(), bundle);
+            for (String key : reference.stringPropertyNames()) {
+                assertFalse(candidate.getProperty(key).isBlank(), bundle + " blank " + key);
+                assertEquals(placeholders(reference.getProperty(key)),
+                        placeholders(candidate.getProperty(key)), bundle + " placeholders " + key);
+            }
+        }
+    }
+
+    @Test
+    void messageSourceLoadsThaiMessagesAndFormatsArguments() {
+        ResourceBundleMessageSource source = new ResourceBundleMessageSource();
+        source.setBasename("i18n/messages");
+        source.setDefaultEncoding(StandardCharsets.UTF_8.name());
+        Locale thai = new Locale("th", "TH");
+        assertEquals("ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง",
+                source.getMessage("flash.delivery.quote.changed", null, thai));
+        assertEquals("ป้อนรหัสผ่านผิด 3 ครั้ง บัญชีถูกล็อก 10 นาที",
+                source.getMessage("user.password.retry.limit.exceed", new Object[]{3, 10}, thai));
+    }
+
+    private Properties load(String file) throws Exception {
+        Properties properties = new Properties();
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream("i18n/" + file)) {
+            assertNotNull(input, file);
+            properties.load(new InputStreamReader(input, StandardCharsets.UTF_8));
+        }
+        return properties;
+    }
+
+    private List<String> placeholders(String value) {
+        List<String> result = new ArrayList<>();
+        Matcher matcher = PLACEHOLDER.matcher(value);
+        while (matcher.find()) result.add(matcher.group());
+        return result;
+    }
+}
+```
+
+- [ ] **Step 2: 将泰语文件纳入闪送消息契约**
+
+在 `FlashDeliveryI18nContractTest.FILES` 末尾增加:
+
+```java
+"messages_th_TH.properties"
+```
+
+保留现有全部闪送与地址消息 key。
+
+- [ ] **Step 3: 创建完整泰语语言包**
+
+以 `messages_zh_TW.properties` 的 key 和顺序为基准创建 `messages_th_TH.properties`:
+
+- 复制全部 319 个 key,不复制繁体中文值。
+- 将每个值翻译为自然、简洁的泰语系统提示。
+- 保持 `{0}`、`{1}`、`{min}`、`{max}` 等占位符原样。
+- 保持 URL、HTTP(S)、PIN、TWD、LINE、Google、Apple、OMG、ATM、CVS 等技术或品牌标识原样。
+- 文件使用 UTF-8,不使用 `\uXXXX` 转义泰文。
+- 关键值必须精确包含:
+
+```properties
+not.null=* จำเป็นต้องกรอก
+user.login.success=เข้าสู่ระบบสำเร็จ
+no.action.success=ดำเนินการสำเร็จ
+flash.delivery.item.invalid=ข้อมูลจำนวน น้ำหนักรวม หรือรายละเอียดสิ่งของไม่ถูกต้อง
+flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
+user.password.retry.limit.exceed=ป้อนรหัสผ่านผิด {0} ครั้ง บัญชีถูกล็อก {1} นาที
+```
+
+- [ ] **Step 4: 静态检查资源和测试差异,暂不运行 Maven**
+
+运行:
+
+```powershell
+git diff --check -- ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
+```
+
+预期:无错误输出。按全局约束,将测试执行延后到 Task 4。
+
+- [ ] **Step 5: 提交泰语消息资源**
+
+```powershell
+$paths=@(
+  'ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties',
+  'ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java',
+  'ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java'
+)
+git add -- $paths
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "新增完整后端泰语提示" -m "补齐全部后端消息的泰语翻译,并通过键集合、占位符和典型消息契约防止语言包缺失或格式错误。"
+```
+
+### Task 3: 锁定台湾繁体默认语言和 HTTP 泰语切换
+
+**Files:**
+- Create: `ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java`
+- Modify: `ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java`
+
+**Interfaces:**
+- Consumes: `I18nConfig.localeResolver()` 和 `I18nConfig.localeChangeInterceptor()`。
+- Produces: 统一使用 `LocaleUtils.parseLocale(String)` 的 HTTP 语言切换,确保无 `lang` 时为 `zh_TW`,三种泰语代码都切换为 `th_TH`。
+
+- [ ] **Step 1: 编写默认语言与 HTTP 切换测试源码**
+
+```java
+package com.ruoyi.framework.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.servlet.DispatcherServlet;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class BackendI18nConfigTest {
+    @Test
+    void defaultsToTraditionalChinese() {
+        LocaleResolver resolver = new I18nConfig().localeResolver();
+        assertEquals(new Locale("zh", "TW"),
+                resolver.resolveLocale(new MockHttpServletRequest()));
+    }
+
+    @Test
+    void switchesAllThaiAliasesThroughExistingLangParameter() throws Exception {
+        for (String language : new String[]{"th_TH", "th-TH", "th"}) {
+            I18nConfig config = new I18nConfig();
+            LocaleResolver resolver = config.localeResolver();
+            LocaleChangeInterceptor interceptor = config.localeChangeInterceptor();
+            MockHttpServletRequest request = new MockHttpServletRequest();
+            request.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, resolver);
+            request.setParameter("lang", language);
+            interceptor.preHandle(request, new MockHttpServletResponse(), new Object());
+            assertEquals(new Locale("th", "TH"), resolver.resolveLocale(request), language);
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 让 HTTP 拦截器复用白名单解析器**
+
+在 `I18nConfig` 导入 `LocaleUtils`,并只覆盖拦截器的 Locale 值解析;参数名继续使用 `lang`:
+
+```java
+import com.ruoyi.common.utils.LocaleUtils;
+
+@Bean
+public LocaleChangeInterceptor localeChangeInterceptor()
+{
+    LocaleChangeInterceptor lci = new LocaleChangeInterceptor()
+    {
+        @Override
+        protected Locale parseLocaleValue(String localeValue)
+        {
+            return LocaleUtils.parseLocale(localeValue);
+        }
+    };
+    lci.setParamName("lang");
+    return lci;
+}
+```
+
+`localeResolver()` 保持 `slr.setDefaultLocale(Locale.TRADITIONAL_CHINESE)`,不得修改默认语言。
+
+- [ ] **Step 3: 静态检查配置和测试,暂不运行 Maven**
+
+```powershell
+git diff --check -- ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
+```
+
+预期:无错误输出。
+
+- [ ] **Step 4: 提交默认语言和 HTTP 切换契约**
+
+```powershell
+git add -- ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "锁定后端默认语言与泰语切换" -m "让 lang 参数复用受支持语言白名单解析,兼容三种泰语代码,并增加台湾繁体默认语言回归契约。"
+```
+
+### Task 4: 统一验证和交付检查
+
+**Files:**
+- Verify: `ruoyi-common/src/main/java/com/ruoyi/common/utils/LocaleUtils.java`
+- Verify: `ruoyi-framework/src/main/java/com/ruoyi/framework/config/I18nConfig.java`
+- Verify: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/LocaleUtilsTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/common/utils/BackendI18nBundleContractTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/framework/config/BackendI18nConfigTest.java`
+- Verify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java`
+
+**Interfaces:**
+- Consumes: Tasks 1 至 3 的全部生产代码、资源和测试源码。
+- Produces: JDK 21 下可验证的完整后端泰语提示支持。
+
+- [ ] **Step 1: 使用 JDK 21 运行全部相关定向测试**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=LocaleUtilsTest,BackendI18nBundleContractTest,BackendI18nConfigTest,FlashDeliveryI18nContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
+```
+
+预期:四个测试类全部通过,Maven 输出 `BUILD SUCCESS`。
+
+- [ ] **Step 2: 使用 JDK 21 执行后端模块构建**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+预期:`ruoyi-common`、`ruoyi-system`、`ruoyi-framework`、`ruoyi-quartz`、`ruoyi-generator` 和 `ruoyi-admin` 均为 `SUCCESS`。
+
+- [ ] **Step 3: 检查最终差异和未混入文件**
+
+```powershell
+git diff --check
+git status --short
+git log --oneline -4
+```
+
+确认泰语功能提交没有包含 `.claude/homunculus/observations.jsonl`,也没有包含此前未提交的闪送请求 DTO/Controller 注释修改。
+
+- [ ] **Step 4: 报告验证结果和提交 SHA**
+
+交付内容必须列出:
+
+- Locale 解析提交 SHA。
+- 泰语语言包提交 SHA。
+- 默认语言回归测试提交 SHA。
+- 实际执行的测试数量和 Maven 构建结果。
+- 明确说明默认语言仍是 `zh_TW`。
+- 明确说明未修改前端和业务数据多语言映射。

+ 109 - 0
docs/superpowers/plans/2026-09-07-remove-flash-pricing-lock.md

@@ -0,0 +1,109 @@
+# 移除闪送运价专用锁表实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 删除闪送运价专用数据库锁表及调用,同时保留事务、重叠校验、配置版本校验和读取侧异常保护。
+
+**Architecture:** 平台运价增删改直接在现有 Spring 事务中执行,新增和修改先调用 `countOverlapping(...)` 校验;修改继续以 `config_version` 为条件更新。数据库迁移仅追加删表 SQL,不直接执行。
+
+**Tech Stack:** JDK 21、Spring Boot、MyBatis-Plus/MyBatis、MySQL、JUnit 5、Mockito。
+
+## Global Constraints
+
+- 默认分支为 `test`,提交标题和正文使用中文,不使用英文 Conventional Commits 前缀。
+- 只修改闪送运价锁相关代码、测试、规格和 SQL,不暂存工作区原有注释及 observations 文件。
+- 不直接执行数据库 SQL;所有迁移仅追加到 `updatesql/sql.md`。
+- 保留 `@Transactional`、`countOverlapping(...)`、`config_version` 更新条件和读取侧多时段异常检查。
+
+---
+
+### Task 1: 删除闪送运价专用数据库锁
+
+**Files:**
+- Modify: `ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `specs/024-flash-delivery/spec.md`
+- Modify: `specs/024-flash-delivery/design.md`
+- Modify: `specs/024-flash-delivery/data-model.md`
+- Modify: `specs/024-flash-delivery/plan.md`
+- Modify: `specs/024-flash-delivery/tasks.md`
+- Modify: `updatesql/sql.md`
+
+**Interfaces:**
+- Consumes: `FlashDeliveryPricingMapper.countOverlapping(String, String, Long)`、`BaseMapper` CRUD 和 `FlashDeliveryPricing.configVersion`。
+- Produces: 不再暴露 `FlashDeliveryPricingMapper.lockPricing()`;运价接口和请求响应契约保持不变。
+
+- [x] **Step 1: 先让新增运价不依赖专用锁行的行为测试红灯**
+
+删除 `FlashDeliveryApplicationServiceTest.Fixture` 中的以下默认模拟,让既有 `createPricingPersistsImmediatelyActiveTimePeriod` 测试表达“数据库没有专用锁行时仍可新增运价”的目标行为:
+
+```java
+when(pricingMapper.lockPricing()).thenReturn("GLOBAL");
+```
+
+- [x] **Step 2: 运行测试确认旧实现红灯**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=FlashDeliveryApplicationServiceTest#createPricingPersistsImmediatelyActiveTimePeriod" "-Dsurefire.failIfNoSpecifiedTests=false" test
+```
+
+预期:`createPricingPersistsImmediatelyActiveTimePeriod` 因旧实现读取不到 `GLOBAL` 锁行而抛出 `ServiceException`。
+
+- [x] **Step 3: 删除最小生产锁逻辑并修正既有测试**
+
+从 Mapper 删除:
+
+```java
+@Select("""
+        select lock_key
+        from flash_delivery_pricing_lock
+        where lock_key = 'GLOBAL'
+        for update
+        """)
+String lockPricing();
+```
+
+从 `createPricing`、`updatePricing`、`deletePricing` 删除 `lockPricing();`,并删除服务层私有 `lockPricing()` 方法。Mapper 契约测试删除仅验证专用锁 SQL 的测试;应用服务测试删除 `lockPricing()` 的调用顺序断言,但继续验证重叠查询、插入、版本更新和删除行为。
+
+- [x] **Step 4: 同步规格和迁移 SQL**
+
+删除当前数据模型中的 `flash_delivery_pricing_lock`,在规格中明确低频管理场景接受并发新增的极低概率重叠风险;修改仍使用版本校验,读取仍拒绝多条匹配。在 `updatesql/sql.md` 末尾追加:
+
+```markdown
+## 2026-09-07 移除闪送运价专用锁表
+
+\`\`\`sql
+DROP TABLE IF EXISTS flash_delivery_pricing_lock;
+\`\`\`
+```
+
+更新 `tasks.md` 增加并完成本次规格、红灯、实现、验证任务。
+
+- [x] **Step 5: 运行定向测试和模块构建**
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-admin -am "-Dtest=FlashDeliveryPricingMapperContractTest,FlashDeliveryApplicationServiceTest,FlashDeliveryControllerContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+预期:定向测试与七模块构建均为 `BUILD SUCCESS`。
+
+- [x] **Step 6: 核对范围、中文提交并推送**
+
+```powershell
+git diff --check
+git status --short
+git add -- docs/superpowers/plans/2026-09-07-remove-flash-pricing-lock.md ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java specs/024-flash-delivery/spec.md specs/024-flash-delivery/design.md specs/024-flash-delivery/data-model.md specs/024-flash-delivery/plan.md specs/024-flash-delivery/tasks.md updatesql/sql.md
+git diff --cached --name-only
+git diff --cached --check
+git commit -m "移除闪送运价专用锁表" -m "保留事务、时段重叠和版本校验,删除低频后台配置不需要的全局数据库行锁及迁移表。"
+git push origin test
+```
+
+预期:暂存清单不包含工作区原有注释文件和 `.claude/homunculus/observations.jsonl`。

+ 72 - 0
docs/superpowers/specs/2026-09-07-backend-thai-i18n-design.md

@@ -0,0 +1,72 @@
+# 后端泰语提示支持设计
+
+## 目标
+
+在不改变现有默认语言的前提下,为后端系统提示增加完整泰语支持。默认语言继续使用台湾繁体中文 `zh_TW`;只有请求或用户语言明确选择泰语时才返回泰语提示。
+
+本阶段仅处理后端系统消息,包括同步接口错误/成功提示,以及通过用户语言生成的推送和异步通知。平台管理端、商家管理端、App 页面文案,以及商品和门店等业务数据的泰语字段不在本阶段范围内。
+
+## 语言代码
+
+- 后端标准 Locale:`th_TH`。
+- 同时接受客户端常见写法:`th_TH`、`th-TH`、`th`。
+- 上述三种写法统一解析为泰国地区泰语 Locale。
+- 默认 Locale 保持 `zh_TW`,未知、空白或非法语言代码仍回退台湾繁体中文。
+- 现有 `zh_CN`、`zh_TW`、`en_US` 和 `vi` 行为必须保持兼容。
+
+## 语言资源
+
+新增 `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`,以现有完整语言包为键集合,翻译所有后端提示,而不是只翻译闪送模块。
+
+要求:
+
+- 泰语包与 `messages_zh_CN.properties`、`messages_zh_TW.properties`、`messages_en_US.properties`、`messages_vi.properties` 的消息 key 完全一致。
+- 占位符名称、数量和顺序保持一致,例如 `{0}`、`{1}` 不得在翻译中丢失或调换语义。
+- 技术标识、订单号、HTTP、URL、PIN、TWD 等不可翻译的内容保持原义。
+- 缺失 key 仍由 Spring 的基础资源包回退,但契约测试必须阻止正常提交出现缺失 key。
+
+## Locale 解析和消息流
+
+### 同步 HTTP 请求
+
+保留现有 `SessionLocaleResolver` 和 `LocaleChangeInterceptor`,参数名继续使用 `lang`。拦截器通过同一个白名单解析器规范化语言代码,因此客户端使用 `?lang=th_TH`、`?lang=th-TH` 或 `?lang=th` 都会得到泰国地区泰语 Locale。默认 Locale 继续设置为台湾繁体中文。
+
+### 用户语言和异步消息
+
+修复 `LocaleUtils.parseLocale()` 当前无条件返回台湾繁体 Locale 的问题,改为白名单解析:
+
+- 简体中文:`zh_CN`、`zh-CN`、`zh`
+- 台湾繁体:`zh_TW`、`zh-TW`、`tw`
+- 英文:`en_US`、`en-US`、`en`
+- 越南语:`vi_VN`、`vi-VN`、`vi`
+- 泰语:`th_TH`、`th-TH`、`th`
+
+无法识别的值返回台湾繁体默认 Locale,不允许任意 Locale 绕过受支持语言范围。`getUserLocale()` 继续从现有 Redis 用户语言值读取并调用该解析器,使推送和异步通知可以使用泰语。
+
+本阶段不修改 `toLangCode()` 和 `getUserLanguageCode()` 的数字语言映射,因为它们用于商品、门店等业务数据语言选择,而泰语业务数据尚未纳入范围。这样可以避免把泰语错误映射到现有数据列。
+
+## 错误处理与兼容性
+
+- 请求未指定语言:返回台湾繁体提示。
+- 请求指定支持的泰语代码:返回泰语提示。
+- 用户保存泰语偏好:异步通知使用泰语提示。
+- 请求传入未知语言:回退台湾繁体提示,不抛出语言解析异常。
+- 泰语资源中缺少消息 key:Spring 可回退基础资源,但语言包契约测试应在交付前发现该问题。
+
+## 验证
+
+新增或扩展测试,至少覆盖:
+
+1. 五个受支持的本地化语言包(`zh_CN`、`zh_TW`、`en_US`、`vi`、`th_TH`)key 集合一致,泰语包包含全部现有后端消息 key。
+2. `th_TH`、`th-TH`、`th` 都解析为泰国地区泰语。
+3. 空值、未知语言仍解析为 `zh_TW`。
+4. 现有简中、繁中、英文、越南语解析保持正确。
+5. 从 Spring MessageSource 读取典型通用提示和闪送提示时返回泰语,并正确替换参数占位符。
+6. 使用 JDK 21 运行受影响模块的定向测试和编译。
+
+## 非目标
+
+- 不新增数据库泰语字段或数字语言代码。
+- 不翻译商品、分类、门店、活动等业务数据。
+- 不修改平台管理端、商家管理端或 App 的语言选择界面。
+- 不改变默认语言;默认始终为台湾繁体中文。

+ 33 - 0
docs/superpowers/specs/2026-09-07-remove-flash-pricing-lock-design.md

@@ -0,0 +1,33 @@
+# 移除闪送运价专用锁表设计
+
+## 背景与目标
+
+`flash_delivery_pricing_lock` 当前通过固定 `GLOBAL` 行和 `SELECT ... FOR UPDATE` 串行化平台运价增删改。闪送运价属于低频管理员配置,业务接受双击、重试或多页面同时操作时极低概率的并发新增冲突,因此移除专用锁表及锁代码,降低数据库结构和维护复杂度。
+
+## 保留行为
+
+- `flash_delivery_pricing` 继续保存全局统一、互不重叠的分时段运价。
+- 新增、修改和删除仍在 Spring 事务内执行。
+- 新增和修改仍通过 `countOverlapping(startTime, endTime, excludedId)` 检查时段重叠。
+- 修改仍以 `config_version` 作为更新条件,拒绝旧页面覆盖新配置。
+- 报价和创建订单继续要求目标时间只匹配一个运价时段;异常重叠数据仍显式报错。
+- 运价字段、计价公式、加急费、小费和订单价格快照均不改变。
+
+## 删除内容
+
+- 删除 `FlashDeliveryPricingMapper.lockPricing()` 及其 `SELECT ... FOR UPDATE`。
+- 删除运价新增、修改、删除入口对 `lockPricing()` 的调用和服务层辅助方法。
+- 删除要求锁表及 `FOR UPDATE` 存在的 Mapper 契约测试。
+- 在 `updatesql/sql.md` 追加 `DROP TABLE IF EXISTS flash_delivery_pricing_lock;`,仅记录,不直接执行数据库变更。
+- 更新闪送规格、设计、数据模型、计划和任务说明,不再将专用锁表作为当前架构组成。
+
+## 并发边界
+
+移除锁表后,两个并发新增请求可能同时通过重叠检查并各自写入。该风险由业务明确接受。修改请求仍受 `config_version` 乐观锁保护;读取侧继续检查匹配结果数量,因此即使出现异常重叠,也不会静默选择其中一条价格。
+
+## 验证
+
+- 先删除应用服务测试夹具对固定 `GLOBAL` 锁行的模拟,验证管理员新增运价不应依赖专用锁行;旧实现会因此失败。
+- 删除生产锁逻辑后,移除只验证锁方法调用顺序的内部实现断言,并运行 Mapper、应用服务和 Controller 闪送定向测试。
+- 使用 JDK 21 执行 `ruoyi-admin` 相关模块测试和构建。
+- 提交前检查暂存清单,不能包含工作区原有注释修改或 `.claude/homunculus/observations.jsonl`。

+ 10 - 0
ruoyi-admin/pom.xml

@@ -90,6 +90,11 @@
             <artifactId>httpclient</artifactId>
             <version>4.5.14</version>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>com.google.firebase</groupId>
             <artifactId>firebase-admin</artifactId>
@@ -161,6 +166,11 @@
                     <warName>${project.artifactId}</warName>
                 </configuration>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>3.2.5</version>
+            </plugin>
         </plugins>
         <finalName>${project.artifactId}</finalName>
     </build>

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java

@@ -6,6 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.retry.annotation.EnableRetry;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
 /**
  * 启动程序
@@ -15,6 +16,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
 @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
 @MapperScan("com.ruoyi.**.mapper")
 @EnableAsync
+@EnableScheduling
 @EnableRetry
 public class RuoYiApplication
 {

+ 115 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryAdminController.java

@@ -0,0 +1,115 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryReasonRequest;
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.SecurityUtils;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 平台端闪送管理接口。 */
+@RestController
+@RequestMapping("/system/flashDelivery/admin")
+public class FlashDeliveryAdminController extends BaseController {
+    private final FlashDeliveryApplicationService service;
+
+    public FlashDeliveryAdminController(FlashDeliveryApplicationService service) {
+        this.service = service;
+    }
+
+    /** 查询全部运价配置列表。 */
+    @PreAuthorize("@ss.hasPermi('flash:pricing:list')")
+    @GetMapping("/pricing")
+    public AjaxResult pricing() {
+        return success(service.adminPricing());
+    }
+
+    /** 新增时段运价配置。 */
+    @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
+    @Log(title = "闪送时段运价", businessType = BusinessType.INSERT)
+    @PostMapping("/pricing")
+    public AjaxResult createPricing(@RequestBody FlashDeliveryPricingRequest request) {
+        return success(service.createPricing(SecurityUtils.getUserId(), request));
+    }
+
+    /** 修改时段运价配置,按版本乐观锁更新。 */
+    @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
+    @Log(title = "闪送时段运价", businessType = BusinessType.UPDATE)
+    @PutMapping("/pricing/{id}")
+    public AjaxResult updatePricing(@PathVariable Long id,
+                                    @RequestBody FlashDeliveryPricingRequest request) {
+        return success(service.updatePricing(SecurityUtils.getUserId(), id, request));
+    }
+
+    /** 删除时段运价配置。 */
+    @PreAuthorize("@ss.hasPermi('flash:pricing:edit')")
+    @Log(title = "闪送时段运价", businessType = BusinessType.DELETE)
+    @DeleteMapping("/pricing/{id}")
+    public AjaxResult deletePricing(@PathVariable Long id) {
+        service.deletePricing(id);
+        return success();
+    }
+
+    /**
+     * 分页查询平台闪送订单。
+     *
+     * @param pageNum 页码,从 1 开始,默认 1
+     * @param pageSize 每页数量,默认 10,服务端限制为 1 至 100
+     * @param status 订单状态,选填,精确匹配
+     * @param serviceType 业务场景,选填,仅支持 HELP_SEND 或 HELP_PICKUP
+     * @param orderNo 订单号关键字,选填,模糊匹配
+     * @param userId 发件用户 ID,选填,精确匹配
+     * @param riderId 骑手用户 ID,选填,精确匹配
+     */
+    @PreAuthorize("@ss.hasPermi('flash:order:list')")
+    @GetMapping("/orders")
+    public AjaxResult orders(@RequestParam(defaultValue = "1") int pageNum,
+                             @RequestParam(defaultValue = "10") int pageSize,
+                             @RequestParam(required = false) String status,
+                             @RequestParam(required = false) String serviceType,
+                             @RequestParam(required = false) String orderNo,
+                             @RequestParam(required = false) Long userId,
+                             @RequestParam(required = false) Long riderId) {
+        return success(service.adminOrders(pageNum, pageSize, status, serviceType, orderNo, userId, riderId));
+    }
+
+    /** 平台订单审计详情:订单完整实体、凭证图片与状态日志。 */
+    @PreAuthorize("@ss.hasPermi('flash:order:query')")
+    @GetMapping("/orders/{id}")
+    public AjaxResult detail(@PathVariable Long id) {
+        return success(service.adminDetail(id));
+    }
+
+    /** 平台取消订单,必须填写取消原因。 */
+    @PreAuthorize("@ss.hasPermi('flash:order:cancel')")
+    @Log(title = "闪送订单取消", businessType = BusinessType.UPDATE)
+    @PostMapping("/orders/{id}/cancel")
+    public AjaxResult cancel(@PathVariable Long id, @RequestBody FlashDeliveryReasonRequest request) {
+        service.adminCancel(SecurityUtils.getUserId(), id, request);
+        return success();
+    }
+
+    /** 平台手动将已送达订单置为已完成。 */
+    @PreAuthorize("@ss.hasPermi('flash:order:complete')")
+    @Log(title = "闪送订单完成", businessType = BusinessType.UPDATE)
+    @PostMapping("/orders/{id}/complete")
+    public AjaxResult complete(@PathVariable Long id) {
+        if (!service.complete(SecurityUtils.getUserId(), "ADMIN", id)) {
+            return error(MessageUtils.message("flash.delivery.complete.not.allowed"));
+        }
+        return success();
+    }
+}

+ 77 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -0,0 +1,77 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+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.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+
+/** 骑手端闪送接口;仅 userType=2 的 token 可执行接单和履约操作。 */
+@RestController
+@RequestMapping("/system/flashDelivery/rider")
+@Anonymous
+public class FlashDeliveryRiderController extends BaseController {
+    private final FlashDeliveryApplicationService service;
+    public FlashDeliveryRiderController(FlashDeliveryApplicationService service) { this.service = service; }
+
+    /**
+     * 按页签查询可抢任务或当前骑手自己的任务。
+     *
+     * @param token 登录令牌,用于解析当前骑手 ID
+     * @param page 页码,从 1 开始,默认 1
+     * @param size 每页数量,默认 10,服务端限制为 1 至 100
+     * @param tab 骑手任务页签:newTask、toPickup、delivering、completed 或 cancelled
+     * @param longitude 骑手当前经度;newTask 页签选填,传入时必须与纬度同时提供
+     * @param latitude 骑手当前纬度;newTask 页签选填,传入时必须与经度同时提供
+     */
+    @GetMapping("/orders")
+    @Auth
+    public AjaxResult orders(@RequestHeader String token,
+                             @RequestParam(defaultValue = "1") int page,
+                             @RequestParam(defaultValue = "10") int size,
+                             @RequestParam(defaultValue = "newTask") String tab,
+                             @RequestParam(required = false) BigDecimal longitude,
+                             @RequestParam(required = false) BigDecimal latitude) {
+        return success(service.riderOrders(userId(token), page, size, tab, longitude, latitude));
+    }
+
+    /** 接单前隐藏敏感字段;接单后仅中单骑手可见完整履约信息。 */
+    @GetMapping("/orders/{id}")
+    @Auth
+    public AjaxResult detail(@RequestHeader String token, @PathVariable Long id) {
+        return success(service.riderDetail(userId(token), id));
+    }
+
+    /** 原子抢单;同一订单只能由一个骑手成功接取。 */
+    @PostMapping("/orders/{id}/accept")
+    @Auth
+    public AjaxResult accept(@RequestHeader String token, @PathVariable Long id) {
+        return success(service.accept(userId(token), id));
+    }
+
+    /** 上传取件凭证并将已接单订单推进为已取件。 */
+    @PostMapping("/orders/{id}/pickup")
+    @Auth
+    public AjaxResult pickup(@RequestHeader String token, @PathVariable Long id,
+                             @RequestBody FlashDeliveryProofRequest request) {
+        service.pickup(userId(token), id, request);
+        return success();
+    }
+
+    /** 上传送达凭证并将已取件订单推进为已送达。 */
+    @PostMapping("/orders/{id}/deliver")
+    @Auth
+    public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
+                              @RequestBody FlashDeliveryDeliverRequest request) {
+        service.deliver(userId(token), id, request);
+        return success();
+    }
+
+    private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
+}

+ 118 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -0,0 +1,118 @@
+package com.ruoyi.app.flashdelivery.controller;
+
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
+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.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.*;
+
+/** 用户端闪送接口;用户身份始终从请求头 token 解析。 */
+@RestController
+@RequestMapping("/system/flashDelivery")
+@Anonymous
+public class FlashDeliveryUserController extends BaseController {
+    private final FlashDeliveryApplicationService service;
+    public FlashDeliveryUserController(FlashDeliveryApplicationService service) { this.service = service; }
+
+    /** 获取当前启用的服务类型和公开计价摘要。 */
+    @GetMapping("/home")
+    @Auth
+    public AjaxResult home(@RequestHeader String token) { return success(service.home()); }
+
+    /** 根据取送坐标获取服务端实时报价。 */
+    @PostMapping("/quote")
+    @Auth
+    public AjaxResult quote(@RequestHeader String token, @RequestBody FlashDeliveryQuoteRequest request) {
+        userId(token);
+        return success(service.quote(request));
+    }
+
+    /** 幂等创建闪送订单,客户端不能指定用户、距离或金额。 */
+    @PostMapping("/orders")
+    @Auth
+    public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
+        try {
+            return success(service.create(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
+    }
+
+    /**
+     * 分页查询当前用户参与的闪送订单。
+     *
+     * @param token 登录令牌,用于解析当前用户 ID
+     * @param page 页码,从 1 开始,默认 1
+     * @param size 每页数量,默认 10,服务端限制为 1 至 100
+     * @param role 订单角色;sender 表示我发的,receiver 表示我收的,不传按 sender 处理
+     */
+    @GetMapping("/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));
+    }
+
+    /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */
+    @GetMapping("/orders/{id}")
+    @Auth
+    public AjaxResult detail(@RequestHeader String token, @PathVariable Long id) {
+        return success(service.userDetail(userId(token), id));
+    }
+
+    /** 修改取送信息的预报价,不写订单;沿用该订单计价快照。 */
+    @PostMapping("/orders/{id}/address/quote")
+    @Auth
+    public AjaxResult quoteAddress(@RequestHeader String token, @PathVariable Long id,
+                                   @RequestBody FlashDeliveryAddressChangeRequest request) {
+        return success(service.quoteAddress(userId(token), id, request));
+    }
+
+    /** 确认修改当前订单取送信息;路线或报价变化时返回最新报价。 */
+    @PostMapping("/orders/address")
+    @Auth
+    public AjaxResult updateAddress(@RequestHeader String token,
+                                    @RequestBody FlashDeliveryAddressConfirmRequest request) {
+        try {
+            return success(service.updateAddress(userId(token), request == null ? null : request.getOrderId(), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
+    }
+
+    /** 待接单时追加正整数小费,同步增加订单总金额。 */
+    @PostMapping("/orders/tip")
+    @Auth
+    public AjaxResult addTip(@RequestHeader String token,
+                            @RequestBody FlashDeliveryTipAddRequest request) {
+        return success(service.addTip(userId(token), request == null ? null : request.getOrderId(), request));
+    }
+
+    /** 在待接单或已接单阶段取消本人订单。 */
+    @PostMapping("/orders/{id}/cancel")
+    @Auth
+    public AjaxResult cancel(@RequestHeader String token, @PathVariable Long id,
+                             @RequestBody FlashDeliveryReasonRequest request) {
+        service.userCancel(userId(token), id, request);
+        return success();
+    }
+
+    /** 用户确认已经收到物品,将已送达订单置为已完成。 */
+    @PostMapping("/orders/{id}/confirmReceipt")
+    @Auth
+    public AjaxResult confirmReceipt(@RequestHeader String token, @PathVariable Long id) {
+        service.confirmReceipt(userId(token), id);
+        return success();
+    }
+
+    private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
+}

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

@@ -0,0 +1,14 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 待接单订单修改取送信息;两端均提交完整地址,未修改的一端原样回传。 */
+@Data
+public class FlashDeliveryAddressChangeRequest {
+    /** 用户订单详情或地址报价返回的订单版本,必填。 */
+    private Integer orderVersion;
+    /** 新取件地址,需完整提交,未修改时也原样回传。 */
+    private FlashDeliveryAddressRequest pickup;
+    /** 新收件地址,需完整提交,未修改时也原样回传。 */
+    private FlashDeliveryAddressRequest delivery;
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressConfirmRequest.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 用户确认修改地址时,原样回传本次地址报价中的距离和费用。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryAddressConfirmRequest extends FlashDeliveryAddressChangeRequest {
+    /** 用户订单详情的 id,必填正整数。 */
+    private Long orderId;
+    /** 确认时回传的报价距离(米),与服务端重算结果一致才允许保存。 */
+    private Integer quotedDistanceMeters;
+    /** 确认时回传的报价基础配送费(TWD)。 */
+    private Long quotedBaseDeliveryFee;
+    /** 确认时回传的报价距离费(TWD)。 */
+    private Long quotedDistanceFee;
+    /** 确认时回传的报价加急费(TWD)。 */
+    private Long quotedUrgentFee;
+    /** 确认时回传的报价总金额(TWD)。 */
+    private Long quotedAmount;
+}

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressRequest.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 下单或报价使用的联系人和地理坐标快照。 */
+@Data
+public class FlashDeliveryAddressRequest {
+    /** 联系人姓名,必填,去除首尾空格后最长 64 个字符。 */
+    private String name;
+
+    /** 联系电话,必填,去除首尾空格后最长 32 个字符。 */
+    private String phone;
+
+    /** 完整主地址,必填,去除首尾空格后最长 255 个字符。 */
+    private String address;
+
+    /** 门牌、楼层等详细地址,选填,去除首尾空格后最长 255 个字符。 */
+    private String addressDetail;
+
+    /** 城市,选填,最长 64 个字符;用于骑手接单前的安全区域展示。 */
+    private String city;
+
+    /** 行政区,选填,最长 64 个字符;用于骑手接单前的安全区域展示。 */
+    private String area;
+
+    /** 交接方式或联系说明,选填,最长 40 个字符。 */
+    private String handoffMethod;
+
+    /** 地址经度,必填,取值范围为 -180 至 180。 */
+    private BigDecimal longitude;
+
+    /** 地址纬度,必填,取值范围为 -90 至 90。 */
+    private BigDecimal latitude;
+}

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressView.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 订单用户或中单骑手可见的取送地址业务快照。 */
+@Data
+public class FlashDeliveryAddressView {
+    /** 联系人姓名,骑手接单前不返回。 */
+    private String name;
+    /** 联系电话,骑手接单前不返回。 */
+    private String phone;
+    /** 完整主地址。 */
+    private String address;
+    /** 门牌、楼层等详细地址。 */
+    private String addressDetail;
+    /** 城市。 */
+    private String city;
+    /** 行政区。 */
+    private String area;
+    /** 交接方式或联系说明。 */
+    private String handoffMethod;
+    /** 地址经度,骑手接单前不返回。 */
+    private BigDecimal longitude;
+    /** 地址纬度,骑手接单前不返回。 */
+    private BigDecimal latitude;
+}

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

@@ -0,0 +1,19 @@
+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 lombok.Data;
+
+import java.util.List;
+
+/** 平台审计详情,平台权限通过后才返回完整持久化字段。 */
+@Data
+public class FlashDeliveryAdminOrderDetailView {
+    /** 订单完整持久化实体。 */
+    private FlashDeliveryOrder order;
+    /** 全部凭证图片。 */
+    private List<FlashDeliveryOrderImage> images;
+    /** 状态变更日志。 */
+    private List<FlashDeliveryOrderLog> logs;
+}

+ 47 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java

@@ -0,0 +1,47 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/** 创建闪送订单请求;服务端会重新计算父类中的路线和价格。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryCreateRequest extends FlashDeliveryQuoteRequest {
+    /**
+     * 客户端生成的幂等请求号,必填,去除首尾空格后最长 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;
+
+    /** 是否启用四位交付 PIN,选填;不传时默认为 true。 */
+    private Boolean pinRequired;
+
+    /** 支付方式,选填;4=现金(默认)、6=线下转账。闪送款项直达骑手,仅允许线下方式。 */
+    private String payType;
+
+    /** 寄件图片地址,选填,最多 9 张;每项最长 1000 个字符,允许相对路径。 */
+    private List<String> senderImageUrls;
+
+    /** 用户对本次闪送的备注,选填,最长 500 个字符。 */
+    private String userNote;
+}

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

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 骑手确认送达请求;启用 PIN 的订单必须同时提交四位交付码。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryDeliverRequest extends FlashDeliveryProofRequest {
+    /** 四位交付 PIN;订单启用 PIN 时必填,未启用时可不传。 */
+    private String pinCode;
+}

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

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 用户闪送首页响应,仅包含当前启用的公开服务摘要。 */
+@Data
+public class FlashDeliveryHomeView {
+    /** 开放的业务场景列表(HELP_SEND、HELP_PICKUP)。 */
+    private List<String> serviceTypes;
+    /** 开放的配送等级列表(NORMAL、URGENT)。 */
+    private List<String> deliveryTypes;
+    /** 当前时段各服务的运价摘要,无可用运价时为空列表。 */
+    private List<FlashDeliveryServiceView> services;
+}

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/** App 订单详情;data 直接序列化本对象,不再套 order/images/logs。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
+    /** 用户编辑待接单订单使用的版本;骑手详情不返回。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Integer orderVersion;
+    /** 寄件图片 URL 列表,用户下单时上传,可为空。 */
+    private List<String> senderImageUrls = List.of();
+    /** 取件凭证图片 URL 列表,骑手上传。 */
+    private List<String> pickupImageUrls = List.of();
+    /** 送达凭证图片 URL 列表,骑手上传。 */
+    private List<String> deliveryImageUrls = List.of();
+    /** 骑手公开资料,未接单时为空。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private FlashDeliveryRiderSummaryView rider;
+    /** 交付 PIN,仅启用 PIN 的用户详情返回,骑手端始终为空。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private String deliveryPinCode;
+}

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

@@ -0,0 +1,78 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 用户和中单骑手共用的业务订单视图,不包含持久化及身份内部字段。 */
+@Data
+public class FlashDeliveryOrderView {
+    /** 订单 ID。 */
+    private Long id;
+    /** 订单号。 */
+    private String orderNo;
+    /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
+    private String serviceType;
+    /** 配送等级:NORMAL=普通、URGENT=加急。 */
+    private String deliveryType;
+    /** 订单状态,取值见 FlashDeliveryStatus。 */
+    private String status;
+    /** 物品类别(DOCUMENT、GIFT 等 9 种)。 */
+    private String packageType;
+    /** 物品数量。 */
+    private Integer quantity;
+    /** 重量范围(UP_TO_5_KG 等 4 档)。 */
+    private String weightRange;
+    /** 物品规格说明。 */
+    private String specification;
+    /** 取件方式:NOW=立即、SCHEDULED=预约。 */
+    private String deliveryMode;
+    /** 预约取件时间窗开始时间,立即单为空。 */
+    private Date scheduledPickupStartAt;
+    /** 预约取件时间窗结束时间,立即单为空。 */
+    private Date scheduledPickupEndAt;
+    /** 是否启用交付 PIN。 */
+    private Boolean pinRequired;
+    /** 取件地址快照,骑手接单前联系人、电话和坐标被脱敏。 */
+    private FlashDeliveryAddressView pickup;
+    /** 收件地址快照,骑手接单前联系人、电话和坐标被脱敏。 */
+    private FlashDeliveryAddressView delivery;
+    /** 服务端计算的路线距离(米)。 */
+    private Integer distanceMeters;
+    /** 距离来源:ROUTE=地图路线、STRAIGHT_LINE=降级直线。 */
+    private String distanceSource;
+    /** 预计配送时长(秒)。 */
+    private Integer estimatedDurationSeconds;
+    /** 基础配送费(TWD),已含距离费。 */
+    private Long baseDeliveryFee;
+    /** 距离附加费(TWD)。 */
+    private Long distanceFee;
+    /** 加急费(TWD),普通配送为 0。 */
+    private Long urgentFee;
+    /** 小费(TWD)。 */
+    private Long tipAmount;
+    /** 订单总金额(TWD)。 */
+    private Long amount;
+    /** 币种。 */
+    private String currency;
+    /** 支付方式:4=现金、6=线下转账;款项直达骑手,用户与骑手详情均返回。 */
+    private String payType;
+    /** 用户备注,骑手接单前不返回。 */
+    private String userNote;
+    /** 骑手接单时间。 */
+    private Date acceptedAt;
+    /** 骑手取件时间。 */
+    private Date pickedUpAt;
+    /** 骑手送达时间。 */
+    private Date deliveredAt;
+    /** 订单完成时间。 */
+    private Date completedAt;
+    /** 订单取消时间。 */
+    private Date cancelledAt;
+    /** 取消原因。 */
+    private String cancelReason;
+    /** 创建时间。 */
+    private Date createTime;
+    /** 最后更新时间。 */
+    private Date updateTime;
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 按外卖运费规则计算的闪送费用明细。 */
+@Data
+public class FlashDeliveryPriceBreakdown {
+    /** 计费距离(公里):超出起步距离部分的计费公里数,不足 0.5 公里为 0、0.5 至 1 公里记 1 公里、1 公里以上按实际公里数。 */
+    private BigDecimal billableDistance;
+    /** 距离附加费(TWD)。 */
+    private Long distanceFee;
+    /** 基础配送费(TWD),等于起步价加距离费。 */
+    private Long baseDeliveryFee;
+    /** 加急费(TWD),普通配送为 0。 */
+    private Long urgentFee;
+    /** 小费(TWD)。 */
+    private Long tipAmount;
+    /** 总金额(TWD),等于基础配送费、加急费、小费之和。 */
+    private Long amount;
+}

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 平台新增或修改闪送时段运价的请求;开始时间必须早于结束时间,且各运价时段不能重叠。 */
+@Data
+public class FlashDeliveryPricingRequest {
+    /** 运价时段开始时间,必填,格式为 HH:mm,取值范围 00:00 至 23:59,包含该时刻。 */
+    private String startTime;
+
+    /** 运价时段结束时间,必填,格式为 HH:mm,取值范围 00:01 至 24:00,不包含该时刻。 */
+    private String endTime;
+
+    /** 起步距离,必填,单位为公里,必须大于 0 且不超过 999999.99;服务端按两位小数保存。 */
+    private BigDecimal startingDistance;
+
+    /** 起步价,必填,单位为整数 TWD,必须大于 0。 */
+    private Long startingFare;
+
+    /** 距离计价步长,必填,单位为公里,必须大于 0 且不超过 999999.99;服务端按两位小数保存。 */
+    private BigDecimal distance;
+
+    /** 每个距离计价步长收取的运费,必填,单位为整数 TWD,必须大于 0。 */
+    private Long freight;
+
+    /** 加急费比例,必填,单位为百分比,取值范围为 0 至 999999.99;服务端按两位小数保存。 */
+    private BigDecimal urgentRate;
+
+    /** 最低加急费,必填,单位为整数 TWD,必须大于或等于 0。 */
+    private Long minimumUrgentFee;
+
+    /** 当前运价配置版本;新增时不传,修改时必填,用于防止并发覆盖。 */
+    private Integer configVersion;
+}

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

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 骑手取件或送达时提交的图片凭证,业务层要求 1 至 9 张有效 URL。 */
+@Data
+public class FlashDeliveryProofRequest {
+    /**
+     * 履约凭证图片 URL,必填,数量为 1 至 9 张。
+     * 每项最长 1000 个字符,允许相对路径。
+     */
+    private List<String> imageUrls;
+}

+ 67 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -0,0 +1,67 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 闪送报价请求;帮送/帮取与普通/加急为两个独立维度。 */
+@Data
+public class FlashDeliveryQuoteRequest {
+    /**
+     * 业务场景,必填。
+     * HELP_SEND 表示帮送,HELP_PICKUP 表示帮取;该字段不表示是否加急。
+     */
+    private String serviceType;
+
+    /**
+     * 配送等级,必填。
+     * NORMAL 表示普通配送,URGENT 表示一对一加急配送。
+     */
+    private String deliveryType;
+
+    /**
+     * 物品类别,必填。
+     * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、
+     * FOOD_INGREDIENTS、ELECTRONICS、SMALL_APPLIANCE、OTHER。
+     */
+    private String packageType;
+
+    /** 物品总数量,必填,必须为大于 0 的整数。 */
+    private Integer quantity;
+
+    /**
+     * 物品重量范围,必填。
+     * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG。
+     */
+    private String weightRange;
+
+    /** 物品体积、尺寸或其他规格说明,选填,去除首尾空格后最长 255 个字符。 */
+    private String specification;
+
+    /** 骑手小费,必填,单位为整数新台币;没有小费时传 0,不参与加急费计算。 */
+    private Long tipAmount;
+
+    /**
+     * 取件方式,选填,默认 NOW。
+     * NOW 表示立即取件,SCHEDULED 表示预约取件。
+     */
+    private String deliveryMode;
+
+    /**
+     * 预约取件开始时间。
+     * deliveryMode=SCHEDULED 时必填,必须为当前时间之后且不超过未来三天;NOW 时不得传。
+     */
+    private Date scheduledPickupStartAt;
+
+    /**
+     * 预约取件结束时间。
+     * deliveryMode=SCHEDULED 时必填,必须比开始时间晚 30 分钟;NOW 时不得传。
+     */
+    private Date scheduledPickupEndAt;
+
+    /** 取件联系人、电话、地址、交接方式和经纬度,必填。 */
+    private FlashDeliveryAddressRequest pickup;
+
+    /** 收件联系人、电话、地址、交接方式和经纬度,必填。 */
+    private FlashDeliveryAddressRequest delivery;
+}

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

@@ -0,0 +1,63 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 服务端按当前时段运价生成的闪送报价。 */
+@Data
+public class FlashDeliveryQuoteView {
+    /** 仅修改订单地址的报价返回,确认保存时原样回传。 */
+    @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+    private Integer orderVersion;
+    /** 业务场景(回显请求值):HELP_SEND=帮送、HELP_PICKUP=帮取。 */
+    private String serviceType;
+    /** 配送等级(回显请求值):NORMAL=普通、URGENT=加急。 */
+    private String deliveryType;
+    /** 取件方式(归一化后):NOW=立即、SCHEDULED=预约。 */
+    private String deliveryMode;
+    /** 预约取件时间窗开始时间,立即单为空。 */
+    private java.util.Date scheduledPickupStartAt;
+    /** 预约取件时间窗结束时间,立即单为空。 */
+    private java.util.Date scheduledPickupEndAt;
+    /** 命中的运价配置 ID,创建订单时需回传。 */
+    private Long pricingId;
+    /** 命中运价的时段开始(HH:mm)。 */
+    private String startTime;
+    /** 命中运价的时段结束(HH:mm)。 */
+    private String endTime;
+    /** 服务端计算的路线距离(米)。 */
+    private Integer distanceMeters;
+    /** 距离来源:ROUTE=地图路线、STRAIGHT_LINE=降级直线。 */
+    private String distanceSource;
+    /** 预计配送时长(秒),直线降级时为空。 */
+    private Integer estimatedDurationSeconds;
+    /** 起步距离(公里)。 */
+    private BigDecimal startingDistance;
+    /** 起步价(TWD)。 */
+    private Long startingFare;
+    /** 距离计价步长(公里)。 */
+    private BigDecimal distance;
+    /** 每个步长收取的运费(TWD)。 */
+    private Long freight;
+    /** 计费距离(公里):超出起步距离部分的计费公里数,不足 0.5 公里为 0、0.5 至 1 公里记 1 公里、1 公里以上按实际公里数。 */
+    private BigDecimal billableDistance;
+    /** 距离附加费(TWD),已包含在基础配送费中。 */
+    private Long distanceFee;
+    /** 基础配送费(TWD),等于起步价加距离费。 */
+    private Long baseDeliveryFee;
+    /** 加急费比例(百分比)。 */
+    private BigDecimal urgentRate;
+    /** 最低加急费(TWD)。 */
+    private Long minimumUrgentFee;
+    /** 加急费(TWD),普通配送为 0。 */
+    private Long urgentFee;
+    /** 小费(TWD)。 */
+    private Long tipAmount;
+    /** 订单总金额(TWD),等于基础配送费、加急费、小费之和。 */
+    private Long amount;
+    /** 币种,固定 TWD。 */
+    private String currency;
+    /** 运价配置版本,创建订单时需回传。 */
+    private Integer pricingVersion;
+}

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

@@ -0,0 +1,10 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 用户或平台取消闪送订单时提交的原因。 */
+@Data
+public class FlashDeliveryReasonRequest {
+    /** 取消原因,必填,去除首尾空格后不能为空且最长 500 个字符。 */
+    private String reason;
+}

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

@@ -0,0 +1,66 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 骑手端闪送任务卡片,不包含联系人、电话、精确坐标或内部字段。 */
+@Data
+public class FlashDeliveryRiderOrderListView {
+    /** 订单 ID。 */
+    private Long id;
+    /** 订单号。 */
+    private String orderNo;
+    /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
+    private String serviceType;
+    /** 配送等级:NORMAL=普通、URGENT=加急。 */
+    private String deliveryType;
+    /** 订单状态,取值见 FlashDeliveryStatus。 */
+    private String status;
+    /** 物品类别。 */
+    private String packageType;
+    /** 物品数量。 */
+    private Integer quantity;
+    /** 重量范围。 */
+    private String weightRange;
+    /** 物品规格说明。 */
+    private String specification;
+    /** 取件方式:NOW=立即、SCHEDULED=预约。 */
+    private String deliveryMode;
+    /** 预约取件时间窗开始时间,立即单为空。 */
+    private Date scheduledPickupStartAt;
+    /** 预约取件时间窗结束时间,立即单为空。 */
+    private Date scheduledPickupEndAt;
+    /** 送达时是否需要核对交付 PIN。 */
+    private Boolean pinRequired;
+    /** 取件主地址。 */
+    private String pickupAddress;
+    /** 取件详细地址。 */
+    private String pickupDetailAddress;
+    /** 收件主地址。 */
+    private String deliveryAddress;
+    /** 收件详细地址。 */
+    private String deliveryDetailAddress;
+    /** 骑手当前位置到取件点的直线距离(米),未上传坐标时为空。 */
+    private Integer pickupDistanceMeters;
+    /** 服务端计算的取送路线距离(米)。 */
+    private Integer distanceMeters;
+    /** 预计配送时长(秒)。 */
+    private Integer estimatedDurationSeconds;
+    /** 基础配送费(TWD),已含距离费。 */
+    private Long baseDeliveryFee;
+    /** 距离附加费(TWD)。 */
+    private Long distanceFee;
+    /** 加急费(TWD),普通配送为 0。 */
+    private Long urgentFee;
+    /** 小费(TWD)。 */
+    private Long tipAmount;
+    /** 订单总金额(TWD)。 */
+    private Long amount;
+    /** 币种。 */
+    private String currency;
+    /** 支付方式:4=现金、6=线下转账;骑手抢单前需知道如何收钱。 */
+    private String payType;
+    /** 创建时间。 */
+    private Date createTime;
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderPageView.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 骑手端分页结果及新任务页顶部摘要。 */
+@Data
+public class FlashDeliveryRiderOrderPageView {
+    /** 当前页任务卡片。 */
+    private List<FlashDeliveryRiderOrderListView> records = List.of();
+    /** 总条数。 */
+    private long total;
+    /** 当前页码。 */
+    private long current;
+    /** 每页数量。 */
+    private long size;
+    /** 附近任务总数,仅 newTask 页签返回。 */
+    private Long nearbyTaskCount;
+    /** 附近任务中最高订单金额(TWD),仅 newTask 页签返回。 */
+    private Long highestOrderAmount;
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderSummaryView.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 订单用户可见的骑手公开资料;位置仅在进行中订单返回。 */
+@Data
+public class FlashDeliveryRiderSummaryView {
+    /** 骑手昵称,未设置时使用用户名。 */
+    private String name;
+    /** 骑手头像。 */
+    private String avatar;
+    /** 骑手评分。 */
+    private Double rating;
+    /** IM 用户 ID(字符串形式),用于发起聊天。 */
+    private String imUserId;
+    /** 骑手实时位置经度,仅已接单或已取件状态返回。 */
+    private BigDecimal longitude;
+    /** 骑手实时位置纬度,仅已接单或已取件状态返回。 */
+    private BigDecimal latitude;
+}

+ 34 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java

@@ -0,0 +1,34 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 用户首页当前可用的闪送服务及运价摘要。 */
+@Data
+public class FlashDeliveryServiceView {
+    /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
+    private String serviceType;
+    /** 运价配置 ID。 */
+    private Long pricingId;
+    /** 运价时段开始(HH:mm)。 */
+    private String startTime;
+    /** 运价时段结束(HH:mm)。 */
+    private String endTime;
+    /** 起步距离(公里)。 */
+    private BigDecimal startingDistance;
+    /** 起步价(TWD)。 */
+    private Long startingFare;
+    /** 距离计价步长(公里)。 */
+    private BigDecimal distance;
+    /** 每个步长收取的运费(TWD)。 */
+    private Long freight;
+    /** 加急费比例(百分比)。 */
+    private BigDecimal urgentRate;
+    /** 最低加急费(TWD)。 */
+    private Long minimumUrgentFee;
+    /** 运价配置版本。 */
+    private Integer pricingVersion;
+    /** 币种,固定 TWD。 */
+    private String currency;
+}

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

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/** 待接单订单追加小费;同一版本重复请求不会重复增加金额。 */
+@Data
+public class FlashDeliveryTipAddRequest {
+    /** 用户订单详情的 id,必填正整数。 */
+    private Long orderId;
+    /** 用户订单详情返回的订单版本,必填。 */
+    private Integer orderVersion;
+    /** 本次追加金额,必须为正整数新台币。 */
+    private BigDecimal additionalTipAmount;
+}

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

@@ -0,0 +1,58 @@
+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;
+    /** 订单状态,取值见 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;
+}

+ 44 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/InfoAddressRequest.java

@@ -0,0 +1,44 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+
+/** 用户地址簿新增/修改请求;userId 永远由 token 决定,不对客户端开放。 */
+@Data
+public class InfoAddressRequest {
+    /** 地址记录 ID;新增时不传,修改时必填且必须属于当前 token 用户。 */
+    private Long id;
+
+    /** 联系人姓名,必填,去除首尾空格后最长 64 个字符。 */
+    private String name;
+
+    /** 联系电话,必填,去除首尾空格后最长 32 个字符。 */
+    private String phone;
+
+    /** 完整主地址,必填,去除首尾空格后最长 255 个字符。 */
+    private String address;
+
+    /** 门牌、楼层等详细地址,选填,去除首尾空格后最长 255 个字符。 */
+    private String addressDetail;
+
+    /** 地址经度,必填,取值范围为 -180 至 180。 */
+    private BigDecimal longitude;
+
+    /** 地址纬度,必填,取值范围为 -90 至 90。 */
+    private BigDecimal latitude;
+
+    /** 国家或地区,选填,去除首尾空格后最长 64 个字符。 */
+    private String country;
+
+    /** 省、州或一级行政区,选填,去除首尾空格后最长 64 个字符。 */
+    private String province;
+
+    /** 城市,选填,去除首尾空格后最长 64 个字符。 */
+    private String city;
+
+    /** 区、县或二级行政区,选填,去除首尾空格后最长 64 个字符。 */
+    private String area;
+
+    /** 地址附加信息,选填,去除首尾空格后最长 1000 个字符。 */
+    private String annexes;
+}

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.flashdelivery.exception;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
+
+/** 创建订单时客户端报价已过期,携带服务端最新报价供用户重新确认。 */
+public class FlashDeliveryQuoteChangedException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    private final FlashDeliveryQuoteView latestQuote;
+
+    public FlashDeliveryQuoteChangedException(FlashDeliveryQuoteView latestQuote) {
+        this.latestQuote = latestQuote;
+    }
+
+    public FlashDeliveryQuoteView getLatestQuote() {
+        return latestQuote;
+    }
+}

+ 48 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -0,0 +1,48 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import org.springframework.stereotype.Service;
+
+/** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
+@Service
+public class FlashDeliveryRouteService {
+    private static final double EARTH_RADIUS_METERS = 6_371_000D;
+    private final RouteDistanceProvider provider;
+
+    public FlashDeliveryRouteService(RouteDistanceProvider provider) {
+        this.provider = provider;
+    }
+
+    /** 计算取送两点路线距离:优先地图路线,异常或无结果时降级球面直线距离。 */
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination) {
+        validate(origin);
+        validate(destination);
+        try {
+            RouteDistance route = provider.route(origin.latitude(), origin.longitude(),
+                    destination.latitude(), destination.longitude());
+            if (route != null && route.distanceMeters() > 0) {
+                return route;
+            }
+        } catch (RuntimeException ignored) {
+            // 地图超时、限流或配置缺失不阻断下单,统一进入本地 Haversine 降级。
+        }
+        // Haversine 只作为降级结果,并通过 source=STRAIGHT_LINE 明确告知调用方。
+        double lat1 = Math.toRadians(origin.latitude().doubleValue());
+        double lat2 = Math.toRadians(destination.latitude().doubleValue());
+        double deltaLat = lat2 - lat1;
+        double deltaLon = Math.toRadians(destination.longitude().doubleValue()
+                - origin.longitude().doubleValue());
+        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)));
+        return new RouteDistance(Math.max(1, meters), null, "STRAIGHT_LINE");
+    }
+
+    private 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) {
+            throw new IllegalArgumentException("invalid coordinates");
+        }
+    }
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GeoPoint.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import java.math.BigDecimal;
+
+/** 不可变经纬度坐标,纬度在前、经度在后。 */
+public record GeoPoint(BigDecimal latitude, BigDecimal longitude) { }

+ 92 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -0,0 +1,92 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/** Google Routes API 的服务端实现,密钥只从后端专用字典读取。 */
+@Component
+public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
+    private static final String ENDPOINT = "https://routes.googleapis.com/directions/v2:computeRoutes";
+    private static final RequestConfig TIMEOUTS = RequestConfig.custom()
+            .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
+
+    /** 调用 Google Routes API 获取驾车路线距离与时长;密钥缺失或响应异常时抛错,由上层降级。 */
+    @Override
+    public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude) {
+        // 不复用可能被浏览器端读取的 sys_googlemap_key,防止服务端 API 权限泄露。
+        List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
+        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", "DRIVE");
+        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());
+        // 只请求计价需要的字段,减少响应体及不必要的数据暴露。
+        request.setHeader("X-Goog-FieldMask", "routes.distanceMeters,routes.duration");
+        request.setEntity(new StringEntity(body.toJSONString(), ContentType.APPLICATION_JSON));
+        try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
+             CloseableHttpResponse response = client.execute(request)) {
+            if (response.getStatusLine().getStatusCode() < 200 || response.getStatusLine().getStatusCode() >= 300) {
+                throw new IllegalStateException("google routes non-success response");
+            }
+            JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
+            JSONArray routes = json.getJSONArray("routes");
+            if (routes == null || routes.isEmpty()) {
+                throw new IllegalStateException("google routes empty response");
+            }
+            JSONObject first = routes.getJSONObject(0);
+            int meters = first.getIntValue("distanceMeters");
+            if (meters <= 0) {
+                throw new IllegalStateException("google routes invalid distance");
+            }
+            return new RouteDistance(meters, parseDuration(first.getString("duration")), "ROUTE");
+        } catch (Exception exception) {
+            throw new IllegalStateException("google routes request failed", exception);
+        }
+    }
+
+    private JSONObject waypoint(BigDecimal latitude, BigDecimal longitude) {
+        JSONObject latLng = new JSONObject();
+        latLng.put("latitude", latitude);
+        latLng.put("longitude", longitude);
+        JSONObject location = new JSONObject();
+        location.put("latLng", latLng);
+        JSONObject waypoint = new JSONObject();
+        waypoint.put("location", location);
+        return waypoint;
+    }
+
+    private Integer parseDuration(String value) {
+        if (value == null || !value.endsWith("s")) return null;
+        try {
+            return new BigDecimal(value.substring(0, value.length() - 1)).intValue();
+        } catch (NumberFormatException exception) {
+            return null;
+        }
+    }
+}

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistance.java

@@ -0,0 +1,4 @@
+package com.ruoyi.app.flashdelivery.route;
+
+/** 路线计算结果;source 标识 ROUTE 或 STRAIGHT_LINE。 */
+public record RouteDistance(int distanceMeters, Integer durationSeconds, String source) { }

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

@@ -0,0 +1,11 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import java.math.BigDecimal;
+
+@FunctionalInterface
+/** 外部路线距离提供器,失败时由上层服务负责本地降级。 */
+public interface RouteDistanceProvider {
+    /** 按起终点计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
+    RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
+                        BigDecimal destinationLatitude, BigDecimal destinationLongitude);
+}

+ 1298 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -0,0 +1,1298 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
+import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.flash.*;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URI;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.function.Function;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
+
+/**
+ * 闪送业务编排服务。
+ *
+ * <p>负责报价、订单创建、抢单、履约状态流转、平台介入和审计日志。
+ * 订单身份只接受 Controller 从 token 解析后的用户 ID,客户端传入的金额、距离或操作人均不可信。</p>
+ */
+@Service
+public class FlashDeliveryApplicationService {
+    private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP");
+    private static final Set<String> DELIVERY_TYPES = Set.of("NORMAL", "URGENT");
+    private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
+            "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
+    private static final Set<String> WEIGHT_RANGES = Set.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+            "OVER_10_TO_15_KG", "OVER_15_TO_20_KG");
+    private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
+    /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */
+    private static final String PAY_TYPE_CASH = "4";
+    private static final String PAY_TYPE_TRANSFER = "6";
+    private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
+    private static final int MAX_DISTANCE_METERS = 40_000;
+    private static final BigDecimal MAX_PRICING_DISTANCE = new BigDecimal("999999.99");
+    private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
+    private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
+    private static final SecureRandom PIN_RANDOM = new SecureRandom();
+    private final FlashDeliveryOrderMapper orderMapper;
+    private final FlashDeliveryPricingMapper pricingMapper;
+    private final FlashDeliveryOrderImageMapper imageMapper;
+    private final FlashDeliveryOrderLogMapper logMapper;
+    private final InfoUserMapper userMapper;
+    private final FlashDeliveryRouteService routeService;
+    private final FlashDeliveryPricingCalculator pricingCalculator;
+    private final RiderDeliveryLockService riderDeliveryLockService;
+    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+
+    public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
+                                           FlashDeliveryPricingMapper pricingMapper,
+                                           FlashDeliveryOrderImageMapper imageMapper,
+                                           FlashDeliveryOrderLogMapper logMapper,
+                                           InfoUserMapper userMapper,
+                                           FlashDeliveryRouteService routeService,
+                                           FlashDeliveryPricingCalculator pricingCalculator,
+                                           RiderDeliveryLockService riderDeliveryLockService,
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+        this.orderMapper = orderMapper;
+        this.pricingMapper = pricingMapper;
+        this.imageMapper = imageMapper;
+        this.logMapper = logMapper;
+        this.userMapper = userMapper;
+        this.routeService = routeService;
+        this.pricingCalculator = pricingCalculator;
+        this.riderDeliveryLockService = riderDeliveryLockService;
+        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+    }
+
+    /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
+    public FlashDeliveryHomeView home() {
+        FlashDeliveryHomeView view = new FlashDeliveryHomeView();
+        view.setServiceTypes(SERVICE_TYPES);
+        view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
+        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
+        view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
+                .map(serviceType -> serviceView(pricing, serviceType)).toList());
+        return view;
+    }
+
+    /** 实时报价:校验请求后按服务端路线距离和命中时段的运价计算费用(立即单取当前时段、预约单取预约开始时刻),不写库。 */
+    public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) {
+        // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。
+        QuoteContext context = calculateQuote(request);
+        return quoteView(request, context);
+    }
+
+    /** 将报价上下文组装为对外报价视图,含运价快照字段与费用明细。 */
+    private FlashDeliveryQuoteView quoteView(FlashDeliveryQuoteRequest request, QuoteContext context) {
+        FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
+        view.setServiceType(request.getServiceType());
+        view.setDeliveryType(request.getDeliveryType());
+        view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
+        view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
+        view.setPricingId(context.pricing().getId());
+        view.setStartTime(context.pricing().getStartTime());
+        view.setEndTime(context.pricing().getEndTime());
+        view.setDistanceMeters(context.route().distanceMeters());
+        view.setDistanceSource(context.route().source());
+        view.setEstimatedDurationSeconds(context.route().durationSeconds());
+        view.setStartingDistance(context.pricing().getStartingDistance());
+        view.setStartingFare(context.pricing().getStartingFare());
+        view.setDistance(context.pricing().getDistance());
+        view.setFreight(context.pricing().getFreight());
+        view.setUrgentRate(context.pricing().getUrgentRate());
+        view.setMinimumUrgentFee(context.pricing().getMinimumUrgentFee());
+        applyBreakdown(view, context.breakdown());
+        view.setCurrency("TWD");
+        view.setPricingVersion(context.pricing().getConfigVersion());
+        return view;
+    }
+
+    /**
+     * 幂等创建订单:clientRequestId 与 userId 构成唯一键,重复提交直接返回已有订单。
+     * 创建前按最新条件重新报价,客户端回传报价不一致时抛出 FlashDeliveryQuoteChangedException;
+     * 成功后保存计价快照、寄件图片和初始状态日志。
+     */
+    @Transactional
+    public FlashDeliveryOrderDetailView create(Long userId, FlashDeliveryCreateRequest request) {
+        requireUserId(userId);
+        if (request == null || !hasText(request.getClientRequestId())
+                || request.getClientRequestId().trim().length() > 64) {
+            throw fail("flash.delivery.client.request.id.invalid");
+        }
+        String requestId = request.getClientRequestId().trim();
+        // 用户 ID + clientRequestId 构成幂等键;重复请求直接返回第一次创建的订单。
+        FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
+        if (existing != null) return participantDetail(existing, true, false);
+        if (request.getUserNote() != null && request.getUserNote().length() > 500) {
+            throw fail("flash.delivery.note.too.long");
+        }
+        QuoteContext quote = calculateQuote(request);
+        FlashDeliveryQuoteView latestQuote = quoteView(request, quote);
+        if (!matchesQuotedPrice(request, latestQuote)) {
+            throw new FlashDeliveryQuoteChangedException(latestQuote);
+        }
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
+        String payType = normalizePayType(request.getPayType());
+        List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
+        order.setClientRequestId(requestId);
+        order.setUserId(userId);
+        order.setServiceType(request.getServiceType());
+        order.setDeliveryType(request.getDeliveryType());
+        order.setStatus(WAITING_ACCEPTANCE);
+        order.setPackageType(request.getPackageType());
+        order.setQuantity(request.getQuantity());
+        order.setWeightRange(request.getWeightRange());
+        order.setSpecification(trimToNull(request.getSpecification()));
+        order.setDeliveryMode(deliveryMode);
+        order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
+        order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
+        order.setPinRequired(pinRequired);
+        order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
+        copyAddress(request.getPickup(), order, true);
+        copyAddress(request.getDelivery(), order, false);
+        order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
+        order.setDistanceMeters(quote.route().distanceMeters());
+        order.setDistanceSource(quote.route().source());
+        order.setEstimatedDurationSeconds(quote.route().durationSeconds());
+        order.setAmount(quote.breakdown().getAmount());
+        order.setCurrency("TWD");
+        order.setPayType(payType);
+        order.setPricingId(quote.pricing().getId());
+        order.setPricingVersion(quote.pricing().getConfigVersion());
+        order.setPricingStartTime(quote.pricing().getStartTime());
+        order.setPricingEndTime(quote.pricing().getEndTime());
+        order.setStartingDistance(quote.pricing().getStartingDistance());
+        order.setStartingFare(quote.pricing().getStartingFare());
+        order.setDistance(quote.pricing().getDistance());
+        order.setFreight(quote.pricing().getFreight());
+        // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
+        order.setDistanceFee(quote.breakdown().getDistanceFee());
+        order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee());
+        order.setUrgentRate(quote.pricing().getUrgentRate());
+        order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee());
+        order.setUrgentFee(quote.breakdown().getUrgentFee());
+        order.setTipAmount(quote.breakdown().getTipAmount());
+        order.setUserNote(trimToNull(request.getUserNote()));
+        order.setVersion(0);
+        order.setCreateTime(now);
+        order.setUpdateTime(now);
+        try {
+            orderMapper.insert(order);
+        } catch (DataIntegrityViolationException duplicate) {
+            // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
+            FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
+            if (concurrent != null) return participantDetail(concurrent, true, false);
+            throw duplicate;
+        }
+        saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
+        writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
+        return participantDetail(order, true, false);
+    }
+
+    /** 分页查询当前用户参与的订单卡片;role=sender 按寄件人、receiver 按收件人过滤。 */
+    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
+        String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";
+        if (!"sender".equals(selectedRole) && !"receiver".equals(selectedRole)) {
+            throw fail("flash.delivery.tab.invalid");
+        }
+        String participantColumn = "receiver".equals(selectedRole) ? "receiver_user_id" : "user_id";
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
+                .eq(participantColumn, userId).orderByDesc("create_time");
+        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::userOrderListView);
+    }
+
+    /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
+    public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
+        return participantDetail(order, true, false);
+    }
+
+    /** 地址修改先报价,用户确认前不改动订单。 */
+    public FlashDeliveryQuoteView quoteAddress(Long userId, Long orderId,
+                                               FlashDeliveryAddressChangeRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion());
+        return calculateAddressQuote(order, request);
+    }
+
+    /** 确认修改待接单订单地址:重算报价并与回传值比对,按版本条件更新。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView updateAddress(Long userId, Long orderId,
+                                                       FlashDeliveryAddressConfirmRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion());
+        FlashDeliveryQuoteView quote = calculateAddressQuote(order, request);
+        if (!Objects.equals(request.getQuotedDistanceMeters(), quote.getDistanceMeters())
+                || !Objects.equals(request.getQuotedBaseDeliveryFee(), quote.getBaseDeliveryFee())
+                || !Objects.equals(request.getQuotedDistanceFee(), quote.getDistanceFee())
+                || !Objects.equals(request.getQuotedUrgentFee(), quote.getUrgentFee())
+                || !Objects.equals(request.getQuotedAmount(), quote.getAmount())) {
+            throw new FlashDeliveryQuoteChangedException(quote);
+        }
+        copyAddress(request.getPickup(), order, true);
+        copyAddress(request.getDelivery(), order, false);
+        order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
+        order.setDistanceMeters(quote.getDistanceMeters());
+        order.setDistanceSource(quote.getDistanceSource());
+        order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
+        // 运价快照与金额同事务刷新为本次计价依据(快照=金额的最后一次计算依据,而非最初依据)
+        order.setPricingId(quote.getPricingId());
+        order.setPricingVersion(quote.getPricingVersion());
+        order.setPricingStartTime(quote.getStartTime());
+        order.setPricingEndTime(quote.getEndTime());
+        order.setStartingDistance(quote.getStartingDistance());
+        order.setStartingFare(quote.getStartingFare());
+        order.setDistance(quote.getDistance());
+        order.setFreight(quote.getFreight());
+        order.setUrgentRate(quote.getUrgentRate());
+        order.setMinimumUrgentFee(quote.getMinimumUrgentFee());
+        order.setDistanceFee(quote.getDistanceFee());
+        order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
+        order.setUrgentFee(quote.getUrgentFee());
+        order.setAmount(quote.getAmount());
+        Date now = new Date();
+        order.setUpdateTime(now);
+        // 即使上面的查询仍显示待接单,骑手也可能已接走,最终以条件 UPDATE 为准。
+        if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        order.setVersion(order.getVersion() + 1);
+        writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "ADDRESS_UPDATED", now);
+        return participantDetail(order, true, false);
+    }
+
+    /** 待接单订单追加小费:订单总额与小费同步增加,版本条件防止并发重复加款。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView addTip(Long userId, Long orderId, FlashDeliveryTipAddRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion());
+        if (request.getAdditionalTipAmount() == null || request.getAdditionalTipAmount().signum() <= 0) {
+            throw fail("flash.delivery.tip.additional.invalid");
+        }
+        long tip;
+        long amount;
+        try {
+            // 保留 JSON 原始数值,禁止 1.9 等小数在绑定时被截断为整数小费。
+            long additionalTip = request.getAdditionalTipAmount().longValueExact();
+            tip = Math.addExact(order.getTipAmount(), additionalTip);
+            amount = Math.addExact(order.getAmount(), additionalTip);
+        } catch (ArithmeticException exception) {
+            throw fail("flash.delivery.tip.additional.invalid");
+        }
+        order.setTipAmount(tip);
+        order.setAmount(amount);
+        Date now = new Date();
+        order.setUpdateTime(now);
+        if (orderMapper.updateWaitingTip(order, request.getOrderVersion()) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        order.setVersion(order.getVersion() + 1);
+        writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "TIP_ADDED", now);
+        return participantDetail(order, true, false);
+    }
+
+    /** 校验并加载可编辑订单:本人、待接单、未绑定骑手且版本一致。 */
+    private FlashDeliveryOrder requireEditableOrder(Long userId, Long orderId, Integer version) {
+        requireUserId(userId);
+        if (orderId == null || orderId <= 0) throw fail("flash.delivery.order.not.found");
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!WAITING_ACCEPTANCE.equals(order.getStatus()) || order.getRiderId() != null) {
+            throw fail("flash.delivery.edit.not.allowed");
+        }
+        if (version == null || version < 0) throw fail("flash.delivery.order.version.invalid");
+        if (!Objects.equals(version, order.getVersion())) throw fail("flash.delivery.state.changed");
+        return order;
+    }
+
+    /** 地址修改报价:使用订单保存的运价快照重算,不采用当前运价,避免改地址顺带变价。 */
+    private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order,
+                                                         FlashDeliveryAddressChangeRequest request) {
+        validateAddress(request.getPickup());
+        validateAddress(request.getDelivery());
+        if (sameAddressText(request.getPickup(), request.getDelivery())
+                || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
+                && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
+            throw fail("flash.delivery.address.same");
+        }
+        RouteDistance route = routeService.calculate(
+                new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        // 待接单尚未成交,改址按"订单定价时点"的最新运价重算(预约单取预约取件时刻、
+        // 立即单取当前),管理员调价对未接单的改址立即生效;时段语义保住,预约单不会用到错误时段的价格。
+        Date pricingDate = "SCHEDULED".equals(order.getDeliveryMode())
+                ? order.getScheduledPickupStartAt() : new Date();
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
+        FlashDeliveryPriceBreakdown breakdown;
+        try {
+            breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
+                    order.getDeliveryType(), order.getTipAmount());
+        } catch (ArithmeticException exception) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+        FlashDeliveryQuoteRequest quoteRequest = new FlashDeliveryQuoteRequest();
+        quoteRequest.setServiceType(order.getServiceType());
+        quoteRequest.setDeliveryType(order.getDeliveryType());
+        quoteRequest.setDeliveryMode(order.getDeliveryMode());
+        quoteRequest.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        quoteRequest.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        FlashDeliveryQuoteView view = quoteView(quoteRequest, new QuoteContext(pricing, route, breakdown));
+        view.setOrderVersion(order.getVersion());
+        return view;
+    }
+
+    /** 用户取消:仅待接单或已接单(实际取件前)允许。 */
+    @Transactional
+    public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
+        String reason = requiredReason(request);
+        Date now = new Date();
+        if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
+    }
+
+    /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */
+    @Transactional
+    public void confirmReceipt(Long userId, Long orderId) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
+        Date now = new Date();
+        if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
+            throw fail("flash.delivery.complete.not.allowed");
+        }
+        writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
+    }
+
+    /**
+     * 骑手任务列表:按页签查询(newTask 可抢任务、toPickup 待取件、delivering 配送中、
+     * completed 已送达/已完成、cancelled 已取消)。newTask 页签额外按骑手坐标做距离过滤和就近排序,
+     * 并返回附近任务数与最高订单金额;其余页签按订单归属骑手查询。
+     */
+    public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize,
+                                                        String tab, BigDecimal longitude, BigDecimal latitude) {
+        InfoUser rider = requireRider(riderId);
+        if (!hasText(tab)) throw fail("flash.delivery.tab.invalid");
+        String selectedTab = tab.trim();
+        // lambda 无法捕获被重新赋值的参数,统一改用仅 newTask 携带坐标的有效变量。
+        BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null;
+        BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null;
+        if ("newTask".equals(selectedTab)) {
+            // 新任务派单按骑手选择的配送类型过滤;本人已接订单页签不受限制。
+            if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
+            validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
+        }
+        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
+                effectiveLongitude, effectiveLatitude, true);
+        IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
+        FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
+        result.setRecords(orders.getRecords().stream()
+                .map(order -> riderOrderListView(order, effectiveLongitude, effectiveLatitude)).toList());
+        result.setTotal(orders.getTotal());
+        result.setCurrent(orders.getCurrent());
+        result.setSize(orders.getSize());
+        if ("newTask".equals(selectedTab)) {
+            result.setNearbyTaskCount(orders.getTotal());
+            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
+                    effectiveLongitude, effectiveLatitude, false)
+                    .select("MAX(amount)");
+            result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
+        }
+        return result;
+    }
+
+    /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */
+    public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
+        InfoUser rider = requireRider(riderId);
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
+            // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。
+            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")) {
+                throw fail("flash.delivery.order.not.found");
+            }
+            // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
+            return participantDetail(order, false, true);
+        }
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        return participantDetail(order, false, false);
+    }
+
+    /** 原子抢单:骑手锁内完成互斥校验与条件更新,同一订单仅一名骑手成功。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
+        InfoUser rider = requireRider(riderId);
+        // 抢单属于获取新任务,要求骑手承接闪送配送。
+        if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
+        return riderDeliveryLockService.withLock(riderId, () -> {
+            FlashDeliveryOrder target = requireOrder(orderId);
+            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
+            Date now = new Date();
+            // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
+            if (orderMapper.accept(orderId, riderId, now) != 1) {
+                throw fail("flash.delivery.order.already.accepted");
+            }
+            writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
+            return participantDetail(requireOrder(orderId), false, false);
+        });
+    }
+
+    /** 骑手取件:提交取件凭证,订单由已接单推进为已取件。 */
+    @Transactional
+    public void pickup(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
+        transitionWithProof(riderId, orderId, request, ACCEPTED, PICKED_UP, "PICKUP", "picked_up_at");
+    }
+
+    /** 骑手送达:校验交付 PIN(启用时)并提交送达凭证,订单由已取件推进为已送达。 */
+    @Transactional
+    public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        if (Boolean.TRUE.equals(order.getPinRequired())
+                && (request == null || !Objects.equals(order.getDeliveryPinCode(), trimToNull(request.getPinCode())))) {
+            throw fail("flash.delivery.pin.invalid");
+        }
+        transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at");
+    }
+
+    /** 平台运价配置列表,按时段开始时间排序。 */
+    public List<FlashDeliveryPricing> adminPricing() {
+        return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
+                .orderByAsc("start_time", "id"));
+    }
+
+    /** 新增运价配置:校验参数合法性与时段不重叠后入库,版本号从 1 开始。 */
+    @Transactional
+    public FlashDeliveryPricing createPricing(Long adminId, FlashDeliveryPricingRequest request) {
+        validatePricingRequest(request, false);
+        ensureNoPricingOverlap(request, null);
+        Date now = new Date();
+        FlashDeliveryPricing pricing = pricingFrom(request);
+        pricing.setConfigVersion(1);
+        pricing.setUpdatedBy(adminId);
+        pricing.setCreateTime(now);
+        pricing.setUpdateTime(now);
+        pricingMapper.insert(pricing);
+        return pricing;
+    }
+
+    /** 修改运价配置:按 configVersion 乐观锁条件更新,防止管理员并发互相覆盖。 */
+    @Transactional
+    public FlashDeliveryPricing updatePricing(Long adminId, Long pricingId,
+                                               FlashDeliveryPricingRequest request) {
+        validatePricingRequest(request, true);
+        FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
+        if (preliminary == null) throw fail("flash.delivery.pricing.not.found");
+        FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
+        if (pricing == null) throw fail("flash.delivery.pricing.not.found");
+        ensureNoPricingOverlap(request, pricingId);
+        Date now = new Date();
+        UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
+                .eq("id", pricingId).eq("config_version", request.getConfigVersion())
+                .set("start_time", request.getStartTime())
+                .set("end_time", request.getEndTime())
+                .set("starting_distance", scaleDistance(request.getStartingDistance()))
+                .set("starting_fare", request.getStartingFare())
+                .set("distance", scaleDistance(request.getDistance()))
+                .set("freight", request.getFreight())
+                .set("urgent_rate", normalizeRate(request.getUrgentRate()))
+                .set("minimum_urgent_fee", request.getMinimumUrgentFee())
+                .set("updated_by", adminId)
+                .set("update_time", now)
+                .setSql("config_version = config_version + 1");
+        // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
+        if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
+        pricing.setStartTime(request.getStartTime());
+        pricing.setEndTime(request.getEndTime());
+        pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
+        pricing.setStartingFare(request.getStartingFare());
+        pricing.setDistance(scaleDistance(request.getDistance()));
+        pricing.setFreight(request.getFreight());
+        pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
+        pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
+        pricing.setConfigVersion(request.getConfigVersion() + 1);
+        pricing.setUpdatedBy(adminId);
+        pricing.setUpdateTime(now);
+        return pricing;
+    }
+
+    /** 删除运价配置。 */
+    @Transactional
+    public void deletePricing(Long pricingId) {
+        FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
+        if (preliminary == null) {
+            throw fail("flash.delivery.pricing.not.found");
+        }
+        FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
+        if (pricing == null) throw fail("flash.delivery.pricing.not.found");
+        if (pricingMapper.deleteById(pricingId) != 1) throw fail("flash.delivery.pricing.not.found");
+    }
+
+    /** 平台订单分页:支持状态、业务场景、订单号模糊、寄件用户、骑手条件过滤。 */
+    public IPage<FlashDeliveryOrder> adminOrders(int pageNum, int pageSize, String status,
+                                                 String serviceType, String orderNo,
+                                                 Long userId, Long riderId) {
+        validateOptionalServiceType(serviceType);
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>().orderByDesc("id");
+        optionalEq(query, "status", status);
+        optionalEq(query, "service_type", serviceType);
+        if (hasText(orderNo)) query.like("order_no", orderNo.trim());
+        if (userId != null) query.eq("user_id", userId);
+        if (riderId != null) query.eq("rider_id", riderId);
+        return orderMapper.selectPage(page(pageNum, pageSize), query);
+    }
+
+    /** 平台订单审计详情:返回完整订单实体、全部凭证图片和状态日志。 */
+    public FlashDeliveryAdminOrderDetailView adminDetail(Long orderId) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView();
+        view.setOrder(order);
+        view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
+                .eq("order_id", orderId).orderByAsc("proof_type", "sort_order")));
+        view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
+                .eq("order_id", orderId).orderByAsc("create_time", "id")));
+        return view;
+    }
+
+    /** 平台取消:任何非终态订单可取消,记录操作人与原因,用于异常履约人工收口。 */
+    @Transactional
+    public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) {
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
+        String reason = requiredReason(request);
+        Date now = new Date();
+        if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
+    }
+
+    /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */
+    @Transactional
+    public boolean complete(Long operatorId, String operatorType, Long orderId) {
+        Date now = new Date();
+        if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
+        writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
+        return true;
+    }
+
+    /** 查询送达时间早于截止点且未完成的订单,供自动完成任务分批处理。 */
+    public List<FlashDeliveryOrder> autoCompleteCandidates(Date deadline, int limit) {
+        return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
+    }
+
+    /** 报价核心:校验枚举、物品、地址与预约窗后,按时段命中运价并计算路线距离,产出报价上下文。 */
+    private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        validateServiceType(request.getServiceType());
+        validateDeliveryType(request.getDeliveryType());
+        if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
+            throw fail("flash.delivery.package.type.invalid");
+        }
+        validateItem(request);
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        validateAddress(request.getPickup());
+        validateAddress(request.getDelivery());
+        if (sameAddressText(request.getPickup(), request.getDelivery())
+                || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
+                && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
+            throw fail("flash.delivery.address.same");
+        }
+        Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
+        // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
+        RouteDistance route = routeService.calculate(
+                new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
+                route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
+        return new QuoteContext(pricing, route, breakdown);
+    }
+
+    /** 查询指定时刻命中的运价配置,未配置时直接报错。 */
+    private FlashDeliveryPricing findPricingAt(String targetTime) {
+        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
+        if (pricing == null) throw fail("flash.delivery.pricing.not.available");
+        return pricing;
+    }
+
+    /** 查询命中运价;命中多条说明配置重叠,报错交由平台修正。 */
+    private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
+        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
+        if (pricing == null || pricing.isEmpty()) return null;
+        if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
+        return pricing.get(0);
+    }
+
+    /** 校验运价请求:时间格式与区间顺序、各金额取值范围及修改时的版本号。 */
+    private void validatePricingRequest(FlashDeliveryPricingRequest request, boolean requireVersion) {
+        if (request == null) throw fail("flash.delivery.pricing.invalid");
+        BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
+        BigDecimal distance = normalizeDistance(request.getDistance());
+        BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
+        if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
+                || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
+                || startingDistance == null || startingDistance.signum() <= 0
+                || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0
+                || request.getStartingFare() == null || request.getStartingFare() <= 0
+                || distance == null || distance.signum() <= 0
+                || distance.compareTo(MAX_PRICING_DISTANCE) > 0
+                || request.getFreight() == null || request.getFreight() <= 0
+                || urgentRate == null || urgentRate.signum() < 0
+                || urgentRate.compareTo(MAX_PRICING_DISTANCE) > 0
+                || request.getMinimumUrgentFee() == null || request.getMinimumUrgentFee() < 0
+                || requireVersion && (request.getConfigVersion() == null || request.getConfigVersion() <= 0)) {
+            throw fail("flash.delivery.pricing.invalid");
+        }
+    }
+
+    /** 确保新时段与既有运价不重叠,excludedId 用于修改时排除自身。 */
+    private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
+        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
+            throw fail("flash.delivery.pricing.overlap");
+        }
+    }
+
+    /** 将运价请求转换为实体,距离与比例统一保留两位小数。 */
+    private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
+        FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setStartTime(request.getStartTime());
+        pricing.setEndTime(request.getEndTime());
+        pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
+        pricing.setStartingFare(request.getStartingFare());
+        pricing.setDistance(scaleDistance(request.getDistance()));
+        pricing.setFreight(request.getFreight());
+        pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
+        pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
+        return pricing;
+    }
+
+    private boolean validStartTime(String value) {
+        return value != null && value.matches("(?:[01]\\d|2[0-3]):[0-5]\\d");
+    }
+
+    private boolean validEndTime(String value) {
+        return "24:00".equals(value) || validStartTime(value);
+    }
+
+    private int toMinute(String value) {
+        if ("24:00".equals(value)) return 24 * 60;
+        return Integer.parseInt(value.substring(0, 2)) * 60 + Integer.parseInt(value.substring(3));
+    }
+
+    private BigDecimal scaleDistance(BigDecimal value) {
+        return normalizeDistance(value);
+    }
+
+    private BigDecimal normalizeDistance(BigDecimal value) {
+        return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private BigDecimal normalizeRate(BigDecimal value) {
+        return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    /** 骑手带凭证的状态推进:校验凭证后按骑手归属条件更新,再落图片与日志。 */
+    private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
+                                     String expected, String next, String proofType, String timeColumn) {
+        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
+        requireRider(riderId);
+        Date now = new Date();
+        if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        saveImages(orderId, proofType, "RIDER", riderId, urls, now);
+        writeLog(orderId, expected, next, "RIDER", riderId, null, now);
+    }
+
+    /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
+    private void saveImages(Long orderId, String proofType, String operatorType,
+                            Long operatorId, List<String> urls, Date now) {
+        for (int index = 0; index < urls.size(); index++) {
+            FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
+            image.setOrderId(orderId);
+            image.setProofType(proofType);
+            image.setImageUrl(urls.get(index));
+            image.setSortOrder(index);
+            image.setOperatorType(operatorType);
+            image.setOperatorId(operatorId);
+            image.setCreateTime(now);
+            imageMapper.insert(image);
+        }
+    }
+
+    /** 校验凭证图片 URL:必填时 1 至 9 张,每项为合法 URI 且不超过 1000 字符。 */
+    private List<String> validateProofUrls(List<String> values, boolean required) {
+        if (values == null || values.isEmpty()) {
+            if (!required) return List.of();
+            throw fail("flash.delivery.proof.required");
+        }
+        if (values.size() > 9) throw fail("flash.delivery.proof.too.many");
+        List<String> urls = new ArrayList<>();
+        for (String value : values) {
+            try {
+                String url = value == null ? null : value.trim();
+                if (url == null || url.isEmpty() || url.length() > 1000) {
+                    throw new IllegalArgumentException();
+                }
+                URI.create(url);
+                urls.add(url);
+            } catch (IllegalArgumentException exception) {
+                throw fail("flash.delivery.proof.url.invalid");
+            }
+        }
+        return urls;
+    }
+
+    /** 组装用户或骑手视角详情;hideSensitiveFields 为 true 时按抢单前脱敏返回。 */
+    private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
+                                                           boolean userView,
+                                                           boolean hideSensitiveFields) {
+        FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
+        if (userView) {
+            view.setOrderVersion(order.getVersion());
+            view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
+            view.setRider(riderSummary(order));
+        }
+        if (!hideSensitiveFields && order.getId() != null) {
+            groupImageUrls(view, imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
+                    .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
+        }
+        return view;
+    }
+
+    /** 订单实体转用户端订单卡片。 */
+    private FlashDeliveryUserOrderListView userOrderListView(FlashDeliveryOrder order) {
+        FlashDeliveryUserOrderListView view = new FlashDeliveryUserOrderListView();
+        view.setId(order.getId());
+        view.setOrderNo(order.getOrderNo());
+        view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
+        view.setStatus(order.getStatus());
+        view.setOrderVersion(order.getVersion());
+        view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setWeightRange(order.getWeightRange());
+        view.setDeliveryMode(order.getDeliveryMode());
+        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        view.setPickupAddress(order.getPickupAddress());
+        view.setPickupDetailAddress(order.getPickupAddressDetail());
+        view.setDeliveryAddress(order.getDeliveryAddress());
+        view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
+        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
+        view.setAmount(order.getAmount());
+        view.setCurrency(order.getCurrency());
+        view.setDeliveredAt(order.getDeliveredAt());
+        view.setCreateTime(order.getCreateTime());
+        return view;
+    }
+
+    /** 订单实体转骑手端任务卡片,可附带骑手到取件点的直线距离。 */
+    private FlashDeliveryRiderOrderListView riderOrderListView(FlashDeliveryOrder order,
+                                                                 BigDecimal longitude, BigDecimal latitude) {
+        FlashDeliveryRiderOrderListView view = new FlashDeliveryRiderOrderListView();
+        view.setId(order.getId());
+        view.setOrderNo(order.getOrderNo());
+        view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
+        view.setStatus(order.getStatus());
+        view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setWeightRange(order.getWeightRange());
+        view.setSpecification(order.getSpecification());
+        view.setDeliveryMode(order.getDeliveryMode());
+        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        view.setPinRequired(order.getPinRequired());
+        view.setPickupAddress(order.getPickupAddress());
+        view.setPickupDetailAddress(order.getPickupAddressDetail());
+        view.setDeliveryAddress(order.getDeliveryAddress());
+        view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
+        view.setPayType(order.getPayType());
+        view.setPickupDistanceMeters(distanceMeters(longitude, latitude,
+                order.getPickupLongitude(), order.getPickupLatitude()));
+        view.setDistanceMeters(order.getDistanceMeters());
+        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setDistanceFee(order.getDistanceFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
+        view.setAmount(order.getAmount());
+        view.setCurrency(order.getCurrency());
+        view.setCreateTime(order.getCreateTime());
+        return view;
+    }
+
+    /** 订单实体转详情基础视图。 */
+    private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
+        FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
+        populateOrderView(view, order, includeSensitiveFields);
+        return view;
+    }
+
+    /** 按凭证类型把图片分组为寄件、取件、送达三组 URL。 */
+    private void groupImageUrls(FlashDeliveryOrderDetailView view, List<FlashDeliveryOrderImage> images) {
+        List<String> sender = new ArrayList<>();
+        List<String> pickup = new ArrayList<>();
+        List<String> delivery = new ArrayList<>();
+        for (FlashDeliveryOrderImage image : images) {
+            if ("SENDER".equals(image.getProofType())) sender.add(image.getImageUrl());
+            else if ("PICKUP".equals(image.getProofType())) pickup.add(image.getImageUrl());
+            else if ("DELIVERY".equals(image.getProofType())) delivery.add(image.getImageUrl());
+        }
+        view.setSenderImageUrls(sender);
+        view.setPickupImageUrls(pickup);
+        view.setDeliveryImageUrls(delivery);
+    }
+
+    /** 运价实体转首页服务摘要视图。 */
+    private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing, String serviceType) {
+        FlashDeliveryServiceView view = new FlashDeliveryServiceView();
+        view.setServiceType(serviceType);
+        view.setPricingId(pricing.getId());
+        view.setStartTime(pricing.getStartTime());
+        view.setEndTime(pricing.getEndTime());
+        view.setStartingDistance(pricing.getStartingDistance());
+        view.setStartingFare(pricing.getStartingFare());
+        view.setDistance(pricing.getDistance());
+        view.setFreight(pricing.getFreight());
+        view.setUrgentRate(pricing.getUrgentRate());
+        view.setMinimumUrgentFee(pricing.getMinimumUrgentFee());
+        view.setPricingVersion(pricing.getConfigVersion());
+        view.setCurrency("TWD");
+        return view;
+    }
+
+    /** 将订单实体的公共字段填充到视图,includeSensitiveFields 控制是否包含敏感字段。 */
+    private void populateOrderView(FlashDeliveryOrderView view, FlashDeliveryOrder order,
+                                   boolean includeSensitiveFields) {
+        view.setId(order.getId());
+        view.setOrderNo(order.getOrderNo());
+        view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
+        view.setStatus(order.getStatus());
+        view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setWeightRange(order.getWeightRange());
+        view.setSpecification(order.getSpecification());
+        view.setDeliveryMode(order.getDeliveryMode());
+        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
+        view.setPinRequired(order.getPinRequired());
+        view.setPickup(addressView(order, true, includeSensitiveFields));
+        view.setDelivery(addressView(order, false, includeSensitiveFields));
+        view.setDistanceMeters(order.getDistanceMeters());
+        view.setDistanceSource(order.getDistanceSource());
+        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setDistanceFee(order.getDistanceFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
+        view.setAmount(order.getAmount());
+        view.setCurrency(order.getCurrency());
+        view.setPayType(order.getPayType());
+        view.setUserNote(includeSensitiveFields ? order.getUserNote() : null);
+        view.setAcceptedAt(order.getAcceptedAt());
+        view.setPickedUpAt(order.getPickedUpAt());
+        view.setDeliveredAt(order.getDeliveredAt());
+        view.setCompletedAt(order.getCompletedAt());
+        view.setCancelledAt(order.getCancelledAt());
+        view.setCancelReason(order.getCancelReason());
+        view.setCreateTime(order.getCreateTime());
+        view.setUpdateTime(order.getUpdateTime());
+    }
+
+    /** 从订单实体提取取件或收件地址视图;脱敏时不返回联系人、电话和坐标。 */
+    private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup,
+                                                 boolean includeSensitiveFields) {
+        FlashDeliveryAddressView view = new FlashDeliveryAddressView();
+        if (pickup) {
+            view.setName(includeSensitiveFields ? order.getPickupName() : null);
+            view.setPhone(includeSensitiveFields ? order.getPickupPhone() : null);
+            view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail());
+            view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea());
+            view.setHandoffMethod(order.getPickupHandoffMethod());
+            view.setLongitude(includeSensitiveFields ? order.getPickupLongitude() : null);
+            view.setLatitude(includeSensitiveFields ? order.getPickupLatitude() : null);
+        } else {
+            view.setName(includeSensitiveFields ? order.getDeliveryName() : null);
+            view.setPhone(includeSensitiveFields ? order.getDeliveryPhone() : null);
+            view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail());
+            view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea());
+            view.setHandoffMethod(order.getDeliveryHandoffMethod());
+            view.setLongitude(includeSensitiveFields ? order.getDeliveryLongitude() : null);
+            view.setLatitude(includeSensitiveFields ? order.getDeliveryLatitude() : null);
+        }
+        return view;
+    }
+
+    /** 组装骑手公开资料;仅已接单或已取件状态附带骑手实时位置。 */
+    private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) {
+        if (order.getRiderId() == null) return null;
+        InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
+        if (rider == null) return null;
+        FlashDeliveryRiderSummaryView view = new FlashDeliveryRiderSummaryView();
+        view.setName(hasText(rider.getNickName()) ? rider.getNickName() : rider.getUserName());
+        view.setAvatar(rider.getAvatar());
+        view.setRating(rider.getStar());
+        view.setImUserId(rider.getImUserId() == null ? null : String.valueOf(rider.getImUserId()));
+        if (ACCEPTED.equals(order.getStatus()) || PICKED_UP.equals(order.getStatus())) {
+            view.setLongitude(rider.getLongitude());
+            view.setLatitude(rider.getLatitude());
+        }
+        return view;
+    }
+
+    /** 把费用明细写入报价视图。 */
+    private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
+        view.setBillableDistance(breakdown.getBillableDistance());
+        view.setDistanceFee(breakdown.getDistanceFee());
+        view.setBaseDeliveryFee(breakdown.getBaseDeliveryFee());
+        view.setUrgentFee(breakdown.getUrgentFee());
+        view.setTipAmount(breakdown.getTipAmount());
+        view.setAmount(breakdown.getAmount());
+    }
+
+    /** 把请求地址复制为订单取件或收件快照字段。 */
+    private void copyAddress(FlashDeliveryAddressRequest source, FlashDeliveryOrder target, boolean pickup) {
+        if (pickup) {
+            target.setPickupName(source.getName().trim()); target.setPickupPhone(source.getPhone().trim());
+            target.setPickupAddress(source.getAddress().trim()); target.setPickupAddressDetail(trimToNull(source.getAddressDetail()));
+            target.setPickupCity(trimToNull(source.getCity())); target.setPickupArea(trimToNull(source.getArea()));
+            target.setPickupHandoffMethod(trimToNull(source.getHandoffMethod()));
+            target.setPickupLongitude(source.getLongitude()); target.setPickupLatitude(source.getLatitude());
+        } else {
+            target.setDeliveryName(source.getName().trim()); target.setDeliveryPhone(source.getPhone().trim());
+            target.setDeliveryAddress(source.getAddress().trim()); target.setDeliveryAddressDetail(trimToNull(source.getAddressDetail()));
+            target.setDeliveryCity(trimToNull(source.getCity())); target.setDeliveryArea(trimToNull(source.getArea()));
+            target.setDeliveryHandoffMethod(trimToNull(source.getHandoffMethod()));
+            target.setDeliveryLongitude(source.getLongitude()); target.setDeliveryLatitude(source.getLatitude());
+        }
+    }
+
+    /** 校验地址请求:必填项、长度限制和经纬度取值范围。 */
+    private void validateAddress(FlashDeliveryAddressRequest address) {
+        if (address == null || !hasText(address.getName()) || !hasText(address.getPhone())
+                || !hasText(address.getAddress()) || address.getLatitude() == null || address.getLongitude() == null
+                || address.getName().trim().length() > 64 || address.getPhone().trim().length() > 32
+                || address.getAddress().trim().length() > 255
+                || (address.getAddressDetail() != null && address.getAddressDetail().trim().length() > 255)
+                || tooLong(address.getCity(), 64) || tooLong(address.getArea(), 64)
+                || tooLong(address.getHandoffMethod(), 40)
+                || address.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
+                || address.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0
+                || address.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
+                || address.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0) {
+            throw fail("flash.delivery.address.invalid");
+        }
+    }
+
+    /** 收件手机号归一化后唯一匹配普通用户作为收件人;匹配零个或多个视为无收件人。 */
+    private Long resolveReceiverUserId(String phone) {
+        if (!hasText(phone)) return null;
+        StringBuilder normalized = new StringBuilder(phone.length());
+        for (int index = 0; index < phone.length(); index++) {
+            char value = phone.charAt(index);
+            if (Character.isWhitespace(value) || value == '+' || value == '-'
+                    || value == '(' || value == ')') {
+                continue;
+            }
+            if (value < '0' || value > '9') return null;
+            normalized.append(value);
+        }
+        if (normalized.isEmpty()) return null;
+        List<Long> matches = userMapper.selectOrdinaryUserIdsByNormalizedPhone(normalized.toString());
+        return matches != null && matches.size() == 1 ? matches.get(0) : null;
+    }
+
+    /** 判断用户是否为订单寄件人或匹配收件人。 */
+    private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
+        return Objects.equals(userId, order.getUserId())
+                || Objects.equals(userId, order.getReceiverUserId());
+    }
+
+    /** 校验 token 用户为骑手(userType=2)并返回其资料。 */
+    private InfoUser requireRider(Long riderId) {
+        InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
+        if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");
+        return rider;
+    }
+
+    /** 加载订单,不存在时统一按订单不存在报错。 */
+    private FlashDeliveryOrder requireOrder(Long orderId) {
+        FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectById(orderId);
+        if (order == null) throw fail("flash.delivery.order.not.found");
+        return order;
+    }
+
+    /** 写入状态变更审计日志。 */
+    private void writeLog(Long orderId, String from, String to, String operatorType,
+                          Long operatorId, String reason, Date now) {
+        FlashDeliveryOrderLog log = new FlashDeliveryOrderLog();
+        log.setOrderId(orderId); log.setFromStatus(from); log.setToStatus(to);
+        log.setOperatorType(operatorType); log.setOperatorId(operatorId); log.setReason(reason); log.setCreateTime(now);
+        logMapper.insert(log);
+    }
+
+    /** 校验并提取取消原因。 */
+    private String requiredReason(FlashDeliveryReasonRequest request) {
+        if (request == null || !hasText(request.getReason()) || request.getReason().trim().length() > 500) {
+            throw fail("flash.delivery.cancel.reason.required");
+        }
+        return request.getReason().trim();
+    }
+
+    private void validateServiceType(String value) {
+        if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid");
+    }
+
+    private void validateDeliveryType(String value) {
+        if (!hasText(value) || !DELIVERY_TYPES.contains(value)) {
+            throw fail("flash.delivery.delivery.type.invalid");
+        }
+    }
+
+    /** 校验物品信息:数量、重量范围、规格长度和小费。 */
+    private void validateItem(FlashDeliveryQuoteRequest request) {
+        if (request.getQuantity() == null || request.getQuantity() <= 0
+                || !hasText(request.getWeightRange()) || !WEIGHT_RANGES.contains(request.getWeightRange())
+                || tooLong(request.getSpecification(), 255)) {
+            throw fail("flash.delivery.item.invalid");
+        }
+        if (request.getTipAmount() == null || request.getTipAmount() < 0) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+    }
+
+    /** 支付方式与主订单系统同域(4=现金、6=线下转账);闪送款项直达骑手,仅允许线下方式,缺省现金,下单后锁定。 */
+    private String normalizePayType(String payType) {
+        if (!hasText(payType)) return PAY_TYPE_CASH;
+        String trimmed = payType.trim();
+        return trimmed;
+//        if (PAY_TYPE_CASH.equals(trimmed) || PAY_TYPE_TRANSFER.equals(trimmed)) return trimmed;
+//        throw fail("flash.delivery.pay.type.invalid");
+    }
+
+    /** 将时间转为台北时区 HH:mm,用于命中运价时段。 */
+    private String pricingTargetTime(Date date) {
+        return PRICING_TIME_FORMAT.format(Instant.ofEpochMilli(date.getTime())
+                .atZone(TAIPEI).toLocalTime());
+    }
+
+    /** 比对客户端回传报价与服务端最新报价是否完全一致。 */
+    private boolean matchesQuotedPrice(FlashDeliveryCreateRequest request, FlashDeliveryQuoteView latest) {
+        return Objects.equals(request.getPricingId(), latest.getPricingId())
+                && Objects.equals(request.getPricingVersion(), latest.getPricingVersion())
+                && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee())
+                && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee())
+                && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee())
+                && Objects.equals(request.getQuotedAmount(), latest.getAmount());
+    }
+
+    private void validateOptionalServiceType(String value) {
+        if (hasText(value)) validateServiceType(value);
+    }
+
+    private void requireUserId(Long userId) {
+        if (userId == null || userId <= 0) throw fail("flash.delivery.auth.required");
+    }
+
+    /** 构造分页对象并夹紧页码与每页数量(1 至 100)。 */
+    private Page<FlashDeliveryOrder> page(int pageNum, int pageSize) {
+        return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
+    }
+
+    /** 分页结果实体转换,保留分页元数据。 */
+    private <T> IPage<T> mapPage(IPage<FlashDeliveryOrder> source,
+                                 Function<FlashDeliveryOrder, T> mapper) {
+        Page<T> result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
+        result.setRecords(source.getRecords().stream().map(mapper).toList());
+        return result;
+    }
+
+    /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
+    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
+                                                                BigDecimal longitude, BigDecimal latitude,
+                                                                boolean orderResults) {
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
+        Date now = new Date();
+        switch (tab) {
+            case "newTask" -> {
+                query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                        .and(item -> item.eq("delivery_mode", "NOW")
+                                .or().le("scheduled_pickup_start_at", now));
+                if (longitude != null) {
+                    Integer limitKm = newTaskDistanceLimit();
+                    if (limitKm != null) {
+                        query.apply("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point({0}, {1})) <= {2}",
+                                longitude, latitude, limitKm.longValue() * 1000L);
+                    }
+                    if (orderResults) {
+                        query.orderByAsc("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point(" + longitude
+                                + ", " + latitude + "))");
+                    }
+                } else if (orderResults) {
+                    query.orderByDesc("create_time");
+                }
+            }
+            case "toPickup" -> {
+                query.eq("rider_id", riderId).eq("status", ACCEPTED);
+                if (orderResults) query.orderByAsc("create_time");
+            }
+            case "delivering" -> {
+                query.eq("rider_id", riderId).eq("status", PICKED_UP);
+                if (orderResults) query.orderByAsc("create_time");
+            }
+            case "completed" -> {
+                query.eq("rider_id", riderId).in("status", DELIVERED, COMPLETED);
+                if (orderResults) query.orderByDesc("create_time");
+            }
+            case "cancelled" -> {
+                query.eq("rider_id", riderId).eq("status", CANCELLED);
+                if (orderResults) query.orderByDesc("create_time");
+            }
+            default -> throw fail("flash.delivery.tab.invalid");
+        }
+        return query;
+    }
+
+    /** 校验骑手坐标:经纬度必须成对出现且在合法范围。 */
+    private void validateRiderCoordinates(BigDecimal longitude, BigDecimal latitude) {
+        if ((longitude == null) != (latitude == null)
+                || longitude != null && (longitude.compareTo(BigDecimal.valueOf(-180)) < 0
+                || longitude.compareTo(BigDecimal.valueOf(180)) > 0
+                || latitude.compareTo(BigDecimal.valueOf(-90)) < 0
+                || latitude.compareTo(BigDecimal.valueOf(90)) > 0)) {
+            throw fail("flash.delivery.coordinates.invalid");
+        }
+    }
+
+    /** 读取字典 sys_qs_newtask_distance 的新任务距离上限(公里);缺失或非法时不限距离。 */
+    private Integer newTaskDistanceLimit() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("sys_qs_newtask_distance");
+            if (values != null && !values.isEmpty()) {
+                int limit = Integer.parseInt(values.get(0).getDictValue());
+                return limit > 0 ? limit : null;
+            }
+        } catch (Exception ignored) {
+            // Keep the established rider-list behaviour: absent or malformed configuration disables distance filtering.
+        }
+        return null;
+    }
+
+    /** Haversine 球面直线距离(米),用于骑手列表展示距取件点的距离。 */
+    private Integer distanceMeters(BigDecimal longitude, BigDecimal latitude,
+                                   BigDecimal targetLongitude, BigDecimal targetLatitude) {
+        if (longitude == null || targetLongitude == null || targetLatitude == null) return null;
+        double latitudeRadians = Math.toRadians(latitude.doubleValue());
+        double targetLatitudeRadians = Math.toRadians(targetLatitude.doubleValue());
+        double latitudeDelta = targetLatitudeRadians - latitudeRadians;
+        double longitudeDelta = Math.toRadians(targetLongitude.doubleValue() - longitude.doubleValue());
+        double value = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2)
+                + Math.cos(latitudeRadians) * Math.cos(targetLatitudeRadians)
+                * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2);
+        return (int) Math.round(6_371_000D * 2D * Math.atan2(Math.sqrt(value), Math.sqrt(1D - value)));
+    }
+
+    /** 从 selectObjs 聚合结果中取第一个 Long 值。 */
+    private Long firstLong(List<Object> values) {
+        if (values == null || values.isEmpty() || values.get(0) == null) return null;
+        Object value = values.get(0);
+        if (value instanceof Number number) return number.longValue();
+        return Long.valueOf(String.valueOf(value));
+    }
+
+    /** 取件方式归一化:缺省按 NOW 处理,非法值报错。 */
+    private String normalizeDeliveryMode(String value) {
+        String mode = hasText(value) ? value.trim() : "NOW";
+        if (!DELIVERY_MODES.contains(mode)) throw fail("flash.delivery.mode.invalid");
+        return mode;
+    }
+
+    /** 校验预约窗:NOW 不得携带时间;SCHEDULED 必须是未来 3 天内恰好 30 分钟的窗。 */
+    private void validateSchedule(String mode, Date start, Date end, Date now) {
+        if ("NOW".equals(mode)) {
+            if (start != null || end != null) throw fail("flash.delivery.schedule.invalid");
+            return;
+        }
+        if (start == null || end == null || start.before(now)
+                || end.getTime() - start.getTime() != SCHEDULE_SLOT_MILLIS
+                || start.getTime() - now.getTime() > MAX_SCHEDULE_DELAY_MILLIS) {
+            throw fail("flash.delivery.schedule.invalid");
+        }
+    }
+
+    /** 判断订单当前是否开放抢单:立即单随时可抢,预约单到点开放。 */
+    private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
+        return "NOW".equals(order.getDeliveryMode())
+                || "SCHEDULED".equals(order.getDeliveryMode())
+                && order.getScheduledPickupStartAt() != null
+                && !order.getScheduledPickupStartAt().after(now);
+    }
+
+    private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {
+        if (hasText(value)) query.eq(column, value.trim());
+    }
+
+    private boolean hasText(String value) { return StringUtils.isNotEmpty(value) && !value.trim().isEmpty(); }
+    private String trimToNull(String value) { return hasText(value) ? value.trim() : null; }
+    private boolean tooLong(String value, int max) { return value != null && value.trim().length() > max; }
+    private boolean sameAddressText(FlashDeliveryAddressRequest left, FlashDeliveryAddressRequest right) {
+        return normalized(left.getAddress()).equals(normalized(right.getAddress()))
+                && normalized(left.getAddressDetail()).equals(normalized(right.getAddressDetail()));
+    }
+    private String normalized(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); }
+    private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
+
+    /** 报价中间结果:命中的运价配置、路线距离和费用明细。 */
+    private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route,
+                                FlashDeliveryPriceBreakdown breakdown) { }
+}

+ 76 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -0,0 +1,76 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPriceBreakdown;
+import com.ruoyi.system.domain.flash.FlashDeliveryPricing;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/** 按闪送时段运价计算整数新台币金额。 */
+@Component
+public class FlashDeliveryPricingCalculator {
+    /** 按普通配送、无小费场景计算总金额的便捷入口。 */
+    public Long calculate(FlashDeliveryPricing pricing, int distanceMeters) {
+        return calculateBreakdown(pricing, distanceMeters, "NORMAL", 0L).getAmount();
+    }
+
+    /** 按普通配送、无小费场景计算完整费用明细。 */
+    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters) {
+        return calculateBreakdown(pricing, distanceMeters, "NORMAL", 0L);
+    }
+
+    /**
+     * 费用明细计算:基础配送费 = 起步价 + 距离费;距离费按超起步距离部分、以步长折算的
+     * 每公里单价线性计价,不足 0.5 公里不计、0.5 至 1 公里按 1 公里计;加急费仅 URGENT 收取,
+     * 取最低加急费与基础配送费乘加急比例的较大者;总金额 = 基础配送费 + 加急费 + 小费,均为整数 TWD。
+     */
+    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters,
+                                                           String deliveryType, Long tipAmount) {
+        if (pricing == null || distanceMeters <= 0
+                || pricing.getStartingDistance() == null || pricing.getStartingDistance().signum() <= 0
+                || pricing.getStartingFare() == null || pricing.getStartingFare() <= 0
+                || pricing.getDistance() == null || pricing.getDistance().signum() <= 0
+                || pricing.getFreight() == null || pricing.getFreight() <= 0
+                || pricing.getUrgentRate() == null || pricing.getUrgentRate().signum() < 0
+                || pricing.getMinimumUrgentFee() == null || pricing.getMinimumUrgentFee() < 0
+                || !("NORMAL".equals(deliveryType) || "URGENT".equals(deliveryType))
+                || tipAmount == null || tipAmount < 0) {
+            throw new IllegalArgumentException("invalid pricing or distance");
+        }
+
+        BigDecimal routeDistance = BigDecimal.valueOf(distanceMeters, 3);
+        BigDecimal extraDistance = routeDistance.subtract(pricing.getStartingDistance()).max(BigDecimal.ZERO);
+        BigDecimal chargeableDistance;
+        if (extraDistance.compareTo(new BigDecimal("0.50")) < 0) {
+    // 超距不足 0.5 公里不计费;0.5 至 1 公里按 1 公里计费。
+            chargeableDistance = BigDecimal.ZERO;
+        } else if (extraDistance.compareTo(BigDecimal.ONE) < 0) {
+            chargeableDistance = BigDecimal.ONE;
+        } else {
+            chargeableDistance = extraDistance;
+        }
+
+        BigDecimal unitPrice = BigDecimal.valueOf(pricing.getFreight())
+                .divide(pricing.getDistance(), 8, RoundingMode.HALF_UP);
+        long distanceFee = chargeableDistance.multiply(unitPrice)
+                .setScale(0, RoundingMode.HALF_UP)
+                .longValueExact();
+
+        FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
+        result.setBillableDistance(chargeableDistance.setScale(2, RoundingMode.HALF_UP));
+        result.setDistanceFee(distanceFee);
+        long baseDeliveryFee = Math.addExact(pricing.getStartingFare(), distanceFee);
+        long urgentFee = "URGENT".equals(deliveryType)
+                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(baseDeliveryFee)
+                .multiply(pricing.getUrgentRate())
+                .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
+                .longValueExact())
+                : 0L;
+        result.setBaseDeliveryFee(baseDeliveryFee);
+        result.setUrgentFee(urgentFee);
+        result.setTipAmount(tipAmount);
+        result.setAmount(Math.addExact(Math.addExact(baseDeliveryFee, urgentFee), tipAmount));
+        return result;
+    }
+}

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

@@ -0,0 +1,30 @@
+package com.ruoyi.app.flashdelivery.service;
+
+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)
+                || PICKED_UP.equals(from) && DELIVERED.equals(to)
+                || DELIVERED.equals(from) && COMPLETED.equals(to)
+                || canAdminCancel(from) && CANCELLED.equals(to);
+    }
+
+    public static boolean canUserCancel(String status) {
+        // 用户只能在实际取件前取消;取件后的异常由平台介入处理。
+        return WAITING_ACCEPTANCE.equals(status) || ACCEPTED.equals(status);
+    }
+
+    public static boolean canAdminCancel(String status) {
+        // 平台可取消任何非终态订单,用于异常履约人工收口。
+        return !COMPLETED.equals(status) && !CANCELLED.equals(status);
+    }
+}

+ 117 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/InfoAddressBookService.java

@@ -0,0 +1,117 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.app.flashdelivery.dto.InfoAddressRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.service.IInfoAddressService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 用户共享地址簿服务。
+ * 每个读取和写入条件都绑定 token 对应的 userId,避免通过地址 ID 越权访问他人资料。
+ */
+@Service
+public class InfoAddressBookService {
+    private final IInfoAddressService addressService;
+
+    public InfoAddressBookService(IInfoAddressService addressService) { this.addressService = addressService; }
+
+    /** 按姓名、电话、地址关键字模糊搜索本人地址簿,置顶优先。 */
+    public List<InfoAddress> list(Long userId, String keyword) {
+        QueryWrapper<InfoAddress> query = new QueryWrapper<InfoAddress>().eq("user_id", userId);
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            String value = keyword.trim();
+            query.and(item -> item.like("name", value).or().like("phone", value)
+                    .or().like("address", value).or().like("address_detail", value));
+        }
+        query.orderByDesc("is_top").orderByDesc("top_time").orderByDesc("id");
+        return addressService.list(query);
+    }
+
+    public InfoAddress detail(Long userId, Long id) {
+        // 先查实体再核对所有权;对不存在和越权统一返回无访问权限,避免枚举地址 ID。
+        InfoAddress address = id == null ? null : addressService.getById(id);
+        if (address == null || !Objects.equals(userId, address.getUserId())) throw fail("address.access.denied");
+        return address;
+    }
+
+    /** 新增或更新本人地址;更新用显式 set 以支持清空可选字段。 */
+    @Transactional
+    public InfoAddress save(Long userId, InfoAddressRequest request) {
+        validate(request);
+        InfoAddress address;
+        if (request.getId() == null) {
+            address = new InfoAddress();
+            address.setUserId(userId);
+            copy(request, address);
+            address.setIsTop(false);
+            addressService.save(address);
+        } else {
+            address = detail(userId, request.getId());
+            copy(request, address);
+            // 使用显式 set 更新,使 null 能真正清空门牌、区域等可选字段。
+            UpdateWrapper<InfoAddress> update = new UpdateWrapper<InfoAddress>()
+                    .eq("id", request.getId()).eq("user_id", userId)
+                    .set("name", address.getName()).set("phone", address.getPhone())
+                    .set("address", address.getAddress()).set("address_detail", address.getAddressDetail())
+                    .set("longitude", address.getLongitude()).set("latitude", address.getLatitude())
+                    .set("country", address.getCountry()).set("province", address.getProvince())
+                    .set("city", address.getCity()).set("area", address.getArea())
+                    .set("annexes", address.getAnnexes());
+            if (!addressService.update(update)) throw fail("address.access.denied");
+        }
+        return address;
+    }
+
+    /** 删除本人地址。 */
+    public void delete(Long userId, Long id) {
+        if (!addressService.remove(new QueryWrapper<InfoAddress>().eq("id", id).eq("user_id", userId))) {
+            throw fail("address.access.denied");
+        }
+    }
+
+    /** 置顶本人地址。 */
+    public void top(Long userId, Long id) {
+        detail(userId, id);
+        if (!addressService.update(new UpdateWrapper<InfoAddress>().eq("id", id).eq("user_id", userId)
+                .set("is_top", true).set("top_time", new Date()))) throw fail("address.access.denied");
+    }
+
+    private void validate(InfoAddressRequest request) {
+        if (request == null || blank(request.getName()) || blank(request.getPhone()) || blank(request.getAddress())
+                || tooLong(request.getName(), 64) || tooLong(request.getPhone(), 32)
+                || tooLong(request.getAddress(), 255) || tooLong(request.getAddressDetail(), 255)
+                || tooLong(request.getCountry(), 64) || tooLong(request.getProvince(), 64)
+                || tooLong(request.getCity(), 64) || tooLong(request.getArea(), 64)
+                || tooLong(request.getAnnexes(), 1000)
+                || invalid(request.getLatitude(), -90, 90) || invalid(request.getLongitude(), -180, 180)) {
+            throw fail("address.data.invalid");
+        }
+    }
+
+    private boolean invalid(BigDecimal value, int min, int max) {
+        return value == null || value.compareTo(BigDecimal.valueOf(min)) < 0
+                || value.compareTo(BigDecimal.valueOf(max)) > 0;
+    }
+
+    private boolean blank(String value) { return value == null || value.trim().isEmpty(); }
+    private boolean tooLong(String value, int maximum) { return value != null && value.trim().length() > maximum; }
+    private String trim(String value) { return blank(value) ? null : value.trim(); }
+    private void copy(InfoAddressRequest from, InfoAddress to) {
+        to.setName(from.getName().trim()); to.setPhone(from.getPhone().trim());
+        to.setAddress(from.getAddress().trim()); to.setAddressDetail(trim(from.getAddressDetail()));
+        to.setLongitude(from.getLongitude()); to.setLatitude(from.getLatitude());
+        to.setCountry(trim(from.getCountry())); to.setProvince(trim(from.getProvince()));
+        to.setCity(trim(from.getCity())); to.setArea(trim(from.getArea())); to.setAnnexes(trim(from.getAnnexes()));
+    }
+    private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
+}

+ 34 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/task/FlashDeliveryAutoCompleteTask.java

@@ -0,0 +1,34 @@
+package com.ruoyi.app.flashdelivery.task;
+
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import org.springframework.stereotype.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+/**
+ * 将送达超过 24 小时且用户未确认的订单自动收口为已完成。
+ * 由若依任务调度调用 {@code flashDeliveryAutoCompleteTask.completeDeliveredOrders()}。
+ */
+@Component("flashDeliveryAutoCompleteTask")
+public class FlashDeliveryAutoCompleteTask {
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryAutoCompleteTask.class);
+    private final FlashDeliveryApplicationService service;
+    public FlashDeliveryAutoCompleteTask(FlashDeliveryApplicationService service) { this.service = service; }
+
+    public void completeDeliveredOrders() {
+        Date deadline = Date.from(Instant.now().minus(24, ChronoUnit.HOURS));
+        // 分批处理避免单次扫描过多;条件更新保证与用户确认/平台完成并发时只有一方成功。
+        for (FlashDeliveryOrder order : service.autoCompleteCandidates(deadline, 200)) {
+            try {
+                service.complete(null, "SYSTEM", order.getId());
+            } catch (RuntimeException exception) {
+                log.error("Flash delivery auto-complete failed, orderId={}", order.getId(), exception);
+            }
+        }
+    }
+}

+ 51 - 15
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java

@@ -10,7 +10,9 @@ import com.ruoyi.system.domain.FoodSpecs;
 import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.service.IFoodSpecsService;
 import com.ruoyi.system.service.IFoodSpecsValueService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 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.transaction.annotation.Transactional;
@@ -37,17 +39,21 @@ public class FoodSpecController extends BaseController
 
     @Autowired
     private IFoodSpecsValueService foodSpecsValueService;
-
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
     /**
      * 规格组分页列表(每条带 foodSpecsItems)
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/foodSpecPageList")
-    public AjaxResult foodSpecPageList(@RequestParam(defaultValue = "1") Integer pageNum,
+    public AjaxResult foodSpecPageList(@RequestHeader String token,
+                                       @RequestParam(defaultValue = "1") Integer pageNum,
                                        @RequestParam(defaultValue = "10") Integer pageSize,
                                        @RequestParam Long mdId,
                                        @RequestParam(required = false) String language) {
+        merchantStoreAccessService.requireStoreAccess(
+                AuthContext.requireUserId(), mdId);
         Page<FoodSpecs> page = new Page<>(pageNum, pageSize);
         LambdaQueryWrapper<FoodSpecs> qw = new LambdaQueryWrapper<>();
         qw.eq(FoodSpecs::getMdId, mdId);
@@ -67,14 +73,21 @@ public class FoodSpecController extends BaseController
      * 保存规格组(含级联规格值):id<=0 新增,否则更新
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @PostMapping("/saveFoodSpec")
     @Transactional(rollbackFor = Exception.class)
-    public AjaxResult saveFoodSpec(@RequestBody List<FoodSpecs> list) {
+    public AjaxResult saveFoodSpec(@RequestHeader String token, @RequestBody List<FoodSpecs> list) {
         if (list == null || list.isEmpty()) {
             return error();
         }
+        Long userId = AuthContext.requireUserId();
         for (FoodSpecs s : list) {
+            if (s.getId() == null || s.getId() <= 0) {
+                merchantStoreAccessService.requireStoreAccess(userId, s.getMdId());
+            } else {
+                FoodSpecs existing = requireSpecsAccess(userId, s.getId());
+                s.setMdId(existing.getMdId());
+            }
             if (s.getIsDelete() == null) {
                 s.setIsDelete(false);
             }
@@ -147,9 +160,10 @@ public class FoodSpecController extends BaseController
      * 规格详情(带全部规格值)
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/getSpecs")
-    public AjaxResult getSpecs(@RequestParam Long id) {
+    public AjaxResult getSpecs(@RequestHeader String token, @RequestParam Long id) {
+        requireSpecsAccess(AuthContext.requireUserId(), id);
         FoodSpecs s = foodSpecsService.getById(id);
         if (s != null) {
             List<FoodSpecs> list = new ArrayList<>();
@@ -163,9 +177,10 @@ public class FoodSpecController extends BaseController
      * 软删除规格组(is_delete=1)
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/deleteFoodSpec")
-    public AjaxResult deleteFoodSpec(@RequestParam Long id) {
+    public AjaxResult deleteFoodSpec(@RequestHeader String token, @RequestParam Long id) {
+        requireSpecsAccess(AuthContext.requireUserId(), id);
         FoodSpecs s = new FoodSpecs();
         s.setId(id);
         s.setIsDelete(true);
@@ -176,10 +191,12 @@ public class FoodSpecController extends BaseController
      * 商品编辑页:本门店可用规格(is_open=1 & is_delete=0,仅含 is_open=1 的规格值)
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/getAvailableSpecsList")
-    public AjaxResult getAvailableSpecsList(@RequestParam Long mdId,
+    public AjaxResult getAvailableSpecsList(@RequestHeader String token, @RequestParam Long mdId,
                                             @RequestParam(required = false) String language) {
+        merchantStoreAccessService.requireStoreAccess(
+                AuthContext.requireUserId(), mdId);
         LambdaQueryWrapper<FoodSpecs> qw = new LambdaQueryWrapper<>();
         qw.eq(FoodSpecs::getMdId, mdId);
         qw.eq(FoodSpecs::getIsOpen, true);
@@ -197,9 +214,11 @@ public class FoodSpecController extends BaseController
      * 启停规格组
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/changeOpen")
-    public AjaxResult changeOpen(@RequestParam Long id, @RequestParam Boolean isOpen) {
+    public AjaxResult changeOpen(@RequestHeader String token,
+                                 @RequestParam Long id, @RequestParam Boolean isOpen) {
+        requireSpecsAccess(AuthContext.requireUserId(), id);
         FoodSpecs s = new FoodSpecs();
         s.setId(id);
         s.setIsOpen(isOpen);
@@ -210,15 +229,32 @@ public class FoodSpecController extends BaseController
      * 启停规格值
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/changeSpecValueOpen")
-    public AjaxResult changeSpecValueOpen(@RequestParam Long id, @RequestParam Boolean isOpen) {
+    public AjaxResult changeSpecValueOpen(@RequestHeader String token,
+                                          @RequestParam Long id, @RequestParam Boolean isOpen) {
+        Long userId = AuthContext.requireUserId();
+        FoodSpecsValue existingValue = foodSpecsValueService.getById(id);
+        if (existingValue == null) {
+            return error();
+        }
+        requireSpecsAccess(userId, existingValue.getParentId());
         FoodSpecsValue v = new FoodSpecsValue();
         v.setId(id);
         v.setIsOpen(isOpen);
         return toAjax(foodSpecsValueService.updateById(v));
     }
 
+    private FoodSpecs requireSpecsAccess(Long userId, Long specsId) {
+        FoodSpecs specs = foodSpecsService.getById(specsId);
+        if (specs == null) {
+            throw new com.ruoyi.common.exception.ServiceException(
+                    com.ruoyi.common.utils.MessageUtils.message("merchant.store.access.denied"));
+        }
+        merchantStoreAccessService.requireStoreAccess(userId, specs.getMdId());
+        return specs;
+    }
+
     /**
      * 批量填充规格值到规格组(避免 N+1)
      *

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

@@ -10,7 +10,9 @@ import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.service.IPosFoodService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -38,15 +40,22 @@ public class PosFenleiController extends BaseController
     private IPosFenleiService posFenleiService;
     @Autowired  //商品
     private IPosFoodService posFoodService;
-
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
 
     /**
      * 删除商品分类
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/delefenlei")
-    public AjaxResult delefenlei(@RequestParam String id){
+    public AjaxResult delefenlei(@RequestHeader String token, @RequestParam String id){
+        PosFenlei existing = posFenleiService.getById(Long.valueOf(id));
+        if (existing == null) {
+            return error(MessageUtils.message("no.data.not.exist"));
+        }
+        merchantStoreAccessService.requireStoreAccess(
+                AuthContext.requireUserId(), existing.getMendid());
         QueryWrapper<PosFood> query = new QueryWrapper<>();
         query.eq("fl_id",id);
         List<PosFood> fdlist = posFoodService.list(query);
@@ -60,8 +69,20 @@ public class PosFenleiController extends BaseController
      * 添加或修改商品分类
      */
     @Anonymous
+    @Auth(session = true)
     @PostMapping("/addfenlei")
-    public AjaxResult addfenlei(@RequestBody PosFenlei posFenlei){
+    public AjaxResult addfenlei(@RequestHeader String token, @RequestBody PosFenlei posFenlei){
+        Long userId = AuthContext.requireUserId();
+        if (posFenlei.getId() == null) {
+            merchantStoreAccessService.requireStoreAccess(userId, posFenlei.getMendid());
+        } else {
+            PosFenlei existing = posFenleiService.getById(posFenlei.getId());
+            if (existing == null) {
+                return error(MessageUtils.message("no.data.not.exist"));
+            }
+            merchantStoreAccessService.requireStoreAccess(userId, existing.getMendid());
+            posFenlei.setMendid(existing.getMendid());
+        }
         Boolean org = posFenleiService.saveOrUpdate(posFenlei);
         if(org){
             return success();

+ 49 - 27
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -8,8 +8,8 @@ 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;
-import com.ruoyi.app.utils.ImageCompressUtils;
-import com.ruoyi.common.annotation.Anonymous;
+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;
@@ -20,9 +20,10 @@ 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.system.service.*;
-import com.ruoyi.system.utils.Auth;
-import com.ruoyi.system.utils.JwtUtil;
+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.*;
@@ -73,29 +74,44 @@ public class PosFoodController extends BaseController {
     private IFoodSpecRelationService foodSpecRelationService;
 
     @Autowired //订单电子发票(门店是否能开票)
-    private OrderInvoiceService orderInvoiceService;
-
+    private OrderInvoiceService orderInvoiceService;
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
     //删除商品
     @Anonymous
-    @Auth
-    @GetMapping("/delefood")
-    public AjaxResult delefood(@RequestParam String id) {
-        return toAjax(posFoodService.deletePosFoodById(Long.valueOf(id)));
+    @Auth(session = true)
+    @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
-    @PutMapping("/changerecommend")
-    public AjaxResult changerecommend(@RequestBody PosFood posFood) {
-        return toAjax(posFoodService.saveOrUpdate(posFood));
+    @Anonymous
+    @Auth(session = true)
+    @PutMapping("/changerecommend")
+    public AjaxResult changerecommend(@RequestHeader String token, @RequestBody PosFood posFood) {
+        PosFood existing = merchantStoreAccessService.requireFoodAccess(
+                AuthContext.requireUserId(), posFood.getId());
+        posFood.setMdid(existing.getMdid());
+        return toAjax(posFoodService.saveOrUpdate(posFood));
     }
 
     /**
      * 添加或修改商品
      */
-    @Anonymous
-    @PostMapping("/setposfood")
-    public AjaxResult setposfood(@RequestBody PosFood posFood) {
+    @Anonymous
+    @Auth(session = true)
+    @PostMapping("/setposfood")
+    public AjaxResult setposfood(@RequestHeader String token, @RequestBody PosFood posFood) {
+        Long userId = AuthContext.requireUserId();
+        if (posFood.getId() == null) {
+            merchantStoreAccessService.requireStoreAccess(userId, posFood.getMdid());
+        } else {
+            PosFood existing = merchantStoreAccessService.requireFoodAccess(userId, posFood.getId());
+            posFood.setMdid(existing.getMdid());
+        }
         Boolean org = posFoodService.saveOrUpdate(posFood);
         if (!org) {
             return error();
@@ -153,18 +169,23 @@ public class PosFoodController extends BaseController {
     /**
      * 通过分类查询商品列表
      */
-    @Anonymous
-    @GetMapping("/getidlist")
+    @Anonymous
+    @Auth(session = true)
+    @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) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String ids = jwtUtil.getusid(token);
-        InfoUser user = infoUserService.getById(ids);
+        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);
         }
@@ -421,8 +442,9 @@ public class PosFoodController extends BaseController {
 
 
 
-    @Anonymous
-    @GetMapping("/getFoodPageList")
+    @Anonymous
+    @Auth(session = true)
+    @GetMapping("/getFoodPageList")
     public AjaxResult getFoodPageList(@RequestHeader String token,
                                       @RequestParam Integer page,
                                       @RequestParam Integer size,
@@ -430,8 +452,8 @@ public class PosFoodController extends BaseController {
                                       @RequestParam Long mdId,
                                       @RequestParam(defaultValue = "") String language,
                                       @RequestParam(defaultValue = "") String name) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        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);

+ 122 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -10,6 +10,7 @@ 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.service.UserService;
 import com.ruoyi.app.utils.DateUtil;
 import com.ruoyi.app.utils.ImageCompressUtils;
 import com.ruoyi.app.user.dto.StoreOutput;
@@ -31,6 +32,7 @@ import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.SysConfigMapper;
 import com.ruoyi.system.service.*;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import io.swagger.models.auth.In;
 import org.springframework.beans.BeanUtils;
@@ -80,6 +82,12 @@ public class PosStoreController extends BaseController {
     private IPosOrderService posOrderService;
     @Autowired
     private IPosStoreEzpayService posStoreEzpayService;
+    @Autowired
+    private UserService userService;
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
+    @Autowired
+    private IMerchantSubaccountStoreService merchantSubaccountStoreService;
 
 
 
@@ -115,16 +123,24 @@ public class PosStoreController extends BaseController {
 
     //删除我的门店
     @Anonymous
-    @Auth
+    @Auth(session = true)
+    @Transactional(rollbackFor = Exception.class)
     @GetMapping("/delemendian")
-    public AjaxResult delemendian(@RequestParam String id) {
+    public AjaxResult delemendian(@RequestHeader String token, @RequestParam String id) {
+        Long userId = AuthContext.requireUserId();
+        merchantStoreAccessService.requireOwner(userId);
+        merchantStoreAccessService.requireStoreAccess(userId, Long.valueOf(id));
         QueryWrapper<PosFenlei> queryWrapper = new QueryWrapper<>();
         queryWrapper.eq("mendid", id);
         List<PosFenlei> list = posFenleiService.list(queryWrapper);
         if (list.size() > 0) {
             return error(MessageUtils.message("no.mendian.exist.classify.not.del"));
         } else {
-            return toAjax(posStoreService.deletePosStoreById(Long.valueOf(id)));
+            int rows = posStoreService.deletePosStoreById(Long.valueOf(id));
+            if (rows > 0) {
+                merchantSubaccountStoreService.deleteByStoreId(Long.valueOf(id));
+            }
+            return toAjax(rows);
         }
     }
 
@@ -132,13 +148,12 @@ public class PosStoreController extends BaseController {
      * H5端门店列表
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/storelistlist")
     public AjaxResult storelistlist(@RequestHeader String token,
                                     @RequestParam Integer page,
                                     @RequestParam Integer size) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        Long id = AuthContext.requireUserId();
         IPage<PosStore> stlist = new Page<>(page, size);
         QueryWrapper<PosStore> queryWrapper = new QueryWrapper<>();
 
@@ -151,7 +166,12 @@ public class PosStoreController extends BaseController {
                 queryWrapper.eq("id", -1); // 无关联摊位,返回空列表
             }
         } else {
-            queryWrapper.eq("user_id", id);
+            Set<Long> storeIds = merchantStoreAccessService.getAccessibleStoreIds(id);
+            if (storeIds.isEmpty()) {
+                queryWrapper.eq("id", -1);
+            } else {
+                queryWrapper.in("id", storeIds);
+            }
         }
 
         IPage<PosStore> list = posStoreService.page(stlist, queryWrapper);
@@ -171,13 +191,22 @@ public class PosStoreController extends BaseController {
 
     //查询我的门店列表
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/getmystorelist")
     public AjaxResult getmystorelist(@RequestHeader String token) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
+        Long id = AuthContext.requireUserId();
         QueryWrapper<PosStore> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("user_id", id);
+        InfoUser loginUser = infoUserService.selectInfoUserByUserId(id);
+        if (loginUser != null && "4".equals(loginUser.getUserType())) {
+            queryWrapper.eq("id", loginUser.getStoreId() == null ? -1 : loginUser.getStoreId());
+        } else {
+            Set<Long> storeIds = merchantStoreAccessService.getAccessibleStoreIds(id);
+            if (storeIds.isEmpty()) {
+                queryWrapper.eq("id", -1);
+            } else {
+                queryWrapper.in("id", storeIds);
+            }
+        }
         List<PosStore> list = posStoreService.list(queryWrapper);
         return success(list);
     }
@@ -208,6 +237,29 @@ public class PosStoreController extends BaseController {
 
     }
 
+    /**
+     * 商家收款账户(線下轉賬收银台展示用):门店 到 所属商家主账号 到 审核资料银行三字段
+     * 商家级数据,连锁门店共享同一收款账户;任一字段为空返回 data=null,客户端据此隐藏線下支付选项
+     */
+    @Anonymous
+    @GetMapping("/bankInfo")
+    public AjaxResult bankInfo(@RequestParam Integer id) {
+        PosStore store = posStoreService.getById(id);
+        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())) {
+            return success(null);
+        }
+        JSONObject data = new JSONObject();
+        data.put("accountName", merchant.getBankAccountName().trim());
+        data.put("bankName", merchant.getBankName().trim());
+        data.put("accountNo", merchant.getBankAccountNo().trim());
+        return success(data);
+    }
+
     //根据行业分类查询门店列表
     @Anonymous
     @GetMapping("/getstorelist")
@@ -227,7 +279,11 @@ public class PosStoreController extends BaseController {
 
         posStoreEnrichService.enrichStoreList(list);
 
-        Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, list.size());
+        // total 必须是符合条件总数,不能用当前页行数充当,否则客户端按 total 判断会误判"已加载完"导致翻页失效
+        long total = sort == null
+                ? posStoreMapper.getmdlistCount("%" + area + "%")
+                : posStoreMapper.getdaidiqCount("%" + area + "%", sort);
+        Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, total);
         return success(result);
 
     }
@@ -253,9 +309,24 @@ public class PosStoreController extends BaseController {
 
     //添加门店
     @Anonymous
+    @Auth(session = true)
     @PostMapping("/addmendian")
     @Transactional
-    public AjaxResult addmendian(@RequestBody PosStore posStore) {
+    public AjaxResult addmendian(@RequestHeader String token, @RequestBody PosStore posStore) {
+        Long loginUserId = AuthContext.requireUserId();
+        InfoUser loginUser = infoUserService.selectInfoUserByUserId(loginUserId);
+        if (posStore.getId() == null) {
+            merchantStoreAccessService.requireOwner(loginUserId);
+            userService.checkUserStatus(loginUserId);
+            posStore.setUserId(loginUserId);
+        } else {
+            requireStoreAccess(loginUserId, loginUser, posStore.getId().longValue());
+            PosStore existing = posStoreService.getById(posStore.getId());
+            if (existing == null) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            posStore.setUserId(existing.getUserId());
+        }
         try {
             // 1. 先保存门店信息
             Boolean org = posStoreService.saveOrUpdate(posStore);
@@ -285,8 +356,11 @@ public class PosStoreController extends BaseController {
                 posStoreEzpayService.uploadUbn(store.getId().longValue(), posStore.getUbn());
             }
             return success(MessageUtils.message("no.success"), store);
-        } catch (Exception e) {
-            return error("添加门店失败: " + e.getMessage());
+        } catch (ServiceException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            logger.error("保存商家门店失败,userId:{},storeId:{}", loginUserId, posStore.getId(), exception);
+            return error(MessageUtils.message("no.system.error"));
         }
     }
 
@@ -329,9 +403,11 @@ public class PosStoreController extends BaseController {
      * @return
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @GetMapping("/getMdBusinessHours")
-    public AjaxResult getMdBusinessHours(@RequestParam Long mdId){
+    public AjaxResult getMdBusinessHours(@RequestHeader String token, @RequestParam Long mdId){
+        Long userId = AuthContext.requireUserId();
+        requireStoreAccess(userId, infoUserService.selectInfoUserByUserId(userId), mdId);
         LambdaQueryWrapper<OperatingHours> query=new LambdaQueryWrapper<>();
         query.eq(OperatingHours::getMdId,mdId);
         List<OperatingHours> list = operatingHoursService.list(query);
@@ -348,13 +424,19 @@ public class PosStoreController extends BaseController {
      * @return
      */
     @Anonymous
-    @Auth
+    @Auth(session = true)
     @PostMapping("/saveMdBusinessHours")
-    public AjaxResult saveMdBusinessHours(@RequestBody List<StoreOperatOutput> businessHours) {
+    public AjaxResult saveMdBusinessHours(@RequestHeader String token,
+                                          @RequestBody List<StoreOperatOutput> businessHours) {
         if(businessHours == null || businessHours.isEmpty()) {
             throw new ServiceException(MessageUtils.message("no.mendian.not.set.business.hours"));
         }
         Long mdId = businessHours.get(0).getMdId();
+        Long userId = AuthContext.requireUserId();
+        requireStoreAccess(userId, infoUserService.selectInfoUserByUserId(userId), mdId);
+        if (businessHours.stream().anyMatch(item -> !Objects.equals(mdId, item.getMdId()))) {
+            throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+        }
         PosStore store = posStoreService.getById(businessHours.get(0).getMdId());
         // 先删除旧的营业时间记录
         LambdaQueryWrapper<OperatingHours> query = new LambdaQueryWrapper<>();
@@ -381,6 +463,16 @@ public class PosStoreController extends BaseController {
         return success();
     }
 
+    private void requireStoreAccess(Long loginUserId, InfoUser loginUser, Long storeId) {
+        if (loginUser != null && "4".equals(loginUser.getUserType())) {
+            if (!Objects.equals(loginUser.getStoreId(), storeId)) {
+                throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+            }
+            return;
+        }
+        merchantStoreAccessService.requireStoreAccess(loginUserId, storeId);
+    }
+
     //返回营业时间(按星期分组)
     private List<StoreOperatOutput> getStoreOperatOutput(List<OperatingHours> list, Long mdId){
         List<StoreOperatOutput> data= new ArrayList<>();
@@ -433,9 +525,18 @@ public class PosStoreController extends BaseController {
      * 获取posstore详细信息
      */
     @PreAuthorize("@ss.hasPermi('chanting:store:query')")
-    @GetMapping(value = "/{id}")
+    @GetMapping(value = "/{id:\\d+}")
     public AjaxResult getInfo(@PathVariable("id") Long id) {
-        return success(posStoreService.selectPosStoreById(id));
+        PosStore posStore = posStoreService.selectPosStoreById(id);
+        // 回显营业时间(按周几分组的 timeSlots)与统一编号(统编),供门店审核页显示
+        if (posStore != null) {
+            LambdaQueryWrapper<OperatingHours> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(OperatingHours::getMdId, id);
+            posStore.setBusinessHours(getStoreOperatOutput(operatingHoursService.list(wrapper), id));
+            // 统编实存于 pos_store_ezpay.ubn
+            posStore.setUbn(posStoreEzpayService.getUbn(id));
+        }
+        return success(posStore);
     }
 
     /**

+ 139 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreLinePayController.java

@@ -0,0 +1,139 @@
+package com.ruoyi.app.mendian;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayCredential;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.app.pay.LinePayGatewayAuditService;
+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.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.dto.StoreLinePayCredentialDto;
+import com.ruoyi.system.domain.dto.StoreLinePayToggleDto;
+import com.ruoyi.system.domain.vo.PosStoreLinePayVo;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+/** 平台门店 LINE Pay 凭证版本管理。 */
+@RestController
+@RequestMapping("/system/storeLinePay")
+public class PosStoreLinePayController extends BaseController {
+
+    private final IPosStoreLinePayService credentialService;
+    private final LinePayClient linePayClient;
+    private final String environment;
+    private final LinePayGatewayAuditService auditService;
+
+    public PosStoreLinePayController(IPosStoreLinePayService credentialService,
+                                     LinePayClient linePayClient,
+                                     @Value("${line-pay.environment:sandbox}") String environment) {
+        this(credentialService, linePayClient, environment, null);
+    }
+
+    @Autowired
+    public PosStoreLinePayController(IPosStoreLinePayService credentialService,
+                                     LinePayClient linePayClient,
+                                     @Value("${line-pay.environment:sandbox}") String environment,
+                                     LinePayGatewayAuditService auditService) {
+        this.credentialService = credentialService;
+        this.linePayClient = linePayClient;
+        this.environment = environment.toUpperCase(Locale.ROOT);
+        this.auditService = auditService;
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam(required = false) String posName,
+                              @RequestParam(required = false) Integer isStall,
+                              @RequestParam(required = false) String credentialStatus,
+                              @RequestParam(required = false) Integer isEnabled) {
+        PosStoreLinePayVo query = new PosStoreLinePayVo();
+        query.setPosNameLike(posName);
+        query.setIsStall(isStall);
+        query.setCredentialStatus(credentialStatus);
+        query.setIsEnabled(isEnabled);
+        startPage();
+        List<PosStoreLinePayVo> rows = credentialService.selectStoreList(query);
+        // Mapper list contract deliberately does not select channel_secret.
+        return getDataTable(rows);
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:query')")
+    @GetMapping("/{storeId}")
+    public AjaxResult detail(@PathVariable Long storeId) {
+        return success(credentialService.selectStoreDetail(storeId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:saveCredentials')")
+    @Log(title = "LINE Pay credential", businessType = BusinessType.UPDATE)
+    @PutMapping("/saveCredentials")
+    public AjaxResult saveCredentials(@RequestBody StoreLinePayCredentialDto dto) {
+        if (dto == null || dto.getStoreId() == null || blank(dto.getChannelId())
+                || blank(dto.getChannelSecret())) {
+            return error(MessageUtils.message("line.pay.credential.required"));
+        }
+        LinePayResponse probe;
+        try {
+            LinePayCredential candidate = new LinePayCredential(null,
+                    dto.getChannelId().trim(), dto.getChannelSecret());
+            String probeOrderId = credentialProbeOrderId();
+            probe = auditService == null
+                    ? linePayClient.retrieveByOrderId(candidate, probeOrderId)
+                    : auditService.execute("CREDENTIAL_VERIFY", "ADMIN", null, null,
+                    null, dto.getStoreId(), null, probeOrderId, null,
+                    () -> linePayClient.retrieveByOrderId(candidate, probeOrderId));
+        } catch (Exception e) {
+            return error(MessageUtils.message("line.pay.credential.verify.unknown"));
+        }
+        if (!("1150".equals(probe.returnCode()) || validEmptySuccess(probe))) {
+            return error(MessageUtils.message("line.pay.credential.invalid"));
+        }
+        PosStoreLinePay saved = credentialService.saveVerifiedCredential(dto, environment,
+                probe.returnCode(), probe.returnMessage());
+        return success(MessageUtils.message("line.pay.credential.enabled"), saved);
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:toggleEnable')")
+    @Log(title = "LINE Pay enable", businessType = BusinessType.UPDATE)
+    @PutMapping("/toggleEnable")
+    public AjaxResult toggleEnable(@RequestBody StoreLinePayToggleDto dto) {
+        if (dto == null || dto.getStoreId() == null || dto.getEnabled() == null) {
+            return error(MessageUtils.message("line.pay.credential.required"));
+        }
+        return toAjax(credentialService.setCurrentEnabled(dto.getStoreId(), dto.getEnabled()));
+    }
+
+    private static boolean validEmptySuccess(LinePayResponse response) {
+        if (response == null || !response.isSuccess() || response.rawBody() == null) {
+            return false;
+        }
+        Object info = JSONObject.parseObject(response.rawBody()).get("info");
+        return info instanceof JSONArray && ((JSONArray) info).isEmpty();
+    }
+
+    private static String credentialProbeOrderId() {
+        return "LPVERIFY" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
+    }
+
+    private static boolean blank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreNewebpayController.java

@@ -23,6 +23,9 @@ import java.util.Map;
 /**
  * 平台后台 - 门店蓝新金流支付开通管理。
  *
+ * <p><strong>已废弃(2026-09-02 确认)</strong>:蓝新支付方案已放弃,本接口从未在生产启用。
+ * 不要参考其业务逻辑,不要在其上做增量修改。
+ *
  * <p>蓝新商店注册为线下人工;本接口负责申请状态推进、凭证录入与联网验证(QueryTradeInfo 探测)、
  * 启用开关、支付方式设置。复用 009 PosStoreEzpayController 模式;联网验证放 Controller 层,
  * Service 仅持久化(避免 ruoyi-system 反向依赖 ruoyi-admin 的 NewebPay 工具类)。
@@ -32,6 +35,7 @@ import java.util.Map;
  */
 @RestController
 @RequestMapping("/system/storeNewebpay")
+@Deprecated
 public class PosStoreNewebpayController extends BaseController {
 
     @Autowired

+ 16 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java

@@ -1,7 +1,6 @@
 package com.ruoyi.app.mendian;
 
-import com.ruoyi.app.utils.omg.OmgPay;
-import com.ruoyi.app.utils.omg.OmgPayConfig;
+import com.ruoyi.app.omgpay.OmgStoreCredentialVerifier;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -12,7 +11,6 @@ import com.ruoyi.system.domain.vo.PosStoreOmgVo;
 import com.ruoyi.system.service.IPosStoreOmgService;
 import jakarta.validation.Valid;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -22,13 +20,14 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
  * 平台后台 - 门店 OMG(歐買尬) 支付凭证管理。
  *
  * <p>OMG 商店注册为线下人工;本接口负责申请状态推进、凭证录入与联网验证(QueryTradeInfo 探测)、
- * 启用开关。联网验证放 Controller 层(避免 ruoyi-system 反向依赖 ruoyi-admin 的 OmgPay 工具类),
+ * 启用开关。联网验证放应用层(避免 ruoyi-system 反向依赖 ruoyi-admin),
  * Service 仅持久化。
  *
  * @author ruoyi
@@ -42,10 +41,7 @@ public class PosStoreOmgController extends BaseController {
     private IPosStoreOmgService posStoreOmgService;
 
     @Autowired
-    private OmgPay omgPay;
-
-    @Value("${omg.base-url}")
-    private String omgBaseUrl;
+    private OmgStoreCredentialVerifier credentialVerifier;
 
     /** 门店 OMG 凭证管理列表(分页 + 筛选)。 */
     @PreAuthorize("@ss.hasPermi('chanting:storeOmg:list')")
@@ -79,18 +75,21 @@ public class PosStoreOmgController extends BaseController {
     @Log(title = "OMG录入凭证", businessType = BusinessType.UPDATE)
     @PutMapping("/saveCredentials")
     public AjaxResult saveCredentials(@RequestBody @Valid StoreOmgCredentialDto dto) {
-        OmgPayConfig cfg = new OmgPayConfig(dto.getMerchantId(), dto.getHashKey(), dto.getHashIv());
         Map<String, String> resp;
         try {
-            resp = omgPay.queryTrade(omgBaseUrl, cfg, "OMGVERIFY" + (System.currentTimeMillis() / 1000L));
+            resp = credentialVerifier.verify(dto.getMerchantId(), dto.getHashKey(), dto.getHashIv());
         } catch (Exception e) {
             posStoreOmgService.recordVerifyResult(dto.getStoreId(), "ERROR: " + msg(e));
             return error("OMG 验证服务暂不可用,请稍后重试");
         }
+        if (resp == null || !resp.containsKey("TradeStatus")) {
+            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: invalid signed response");
+            return error("OMG 凭证验证失败,请稍后重试");
+        }
         String rtnMsg = resp == null ? "" : (resp.getOrDefault("RtnMsg", "") + " " + resp.getOrDefault("Message", ""));
         // 金钥/商店代号错误时 OMG 回 CheckMacValue 验证失败或提示 HashKey/商店代号
         if (rtnMsg.contains("CheckMacValue") || rtnMsg.contains("HashKey") || rtnMsg.contains("商店代號") || rtnMsg.contains("商店代号")) {
-            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: " + resp);
+            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: " + auditResponse(resp));
             return error("OMG 凭证无效,请检查商店代号 / HashKey / HashIV");
         }
         posStoreOmgService.enableWithCredentials(dto);
@@ -125,4 +124,10 @@ public class PosStoreOmgController extends BaseController {
     private static String msg(Throwable e) {
         return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
     }
+
+    private static Map<String, String> auditResponse(Map<String, String> response) {
+        Map<String, String> audit = new LinkedHashMap<>(response);
+        audit.remove("CheckMacValue");
+        return audit;
+    }
 }

+ 87 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java

@@ -0,0 +1,87 @@
+package com.ruoyi.app.omgpay;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class OmgCheckMacSigner {
+
+    public String sign(Map<String, String> fields, String hashKey, String hashIv) {
+        requireSecrets(hashKey, hashIv);
+        TreeMap<String, String> sorted = validatedCopy(fields);
+        String query = sorted.entrySet().stream()
+                .map(entry -> entry.getKey() + "=" + entry.getValue())
+                .collect(Collectors.joining("&"));
+        String raw = "HashKey=" + hashKey + "&" + query + "&HashIV=" + hashIv;
+        String encoded = dotNetUrlEncode(raw).toLowerCase(Locale.ROOT);
+        return HexFormat.of().withUpperCase().formatHex(sha256(encoded));
+    }
+
+    private static void requireSecrets(String hashKey, String hashIv) {
+        requirePresent("hashKey", hashKey);
+        requirePresent("hashIv", hashIv);
+    }
+
+    private static TreeMap<String, String> validatedCopy(Map<String, String> fields) {
+        Objects.requireNonNull(fields, "fields must not be null");
+        TreeMap<String, String> sorted = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        for (Map.Entry<String, String> entry : fields.entrySet()) {
+            String key = entry.getKey();
+            String value = entry.getValue();
+            requireNonNull("field key", key);
+            requireNonNull("field value", value);
+            if ("CheckMacValue".equals(key)) {
+                throw new IllegalArgumentException("CheckMacValue must not be supplied");
+            }
+            if (sorted.put(key, value) != null) {
+                throw new IllegalArgumentException("duplicate field key ignoring case");
+            }
+        }
+        return sorted;
+    }
+
+    private static void requirePresent(String name, String value) {
+        if (value == null) {
+            throw new IllegalArgumentException(name + " must not be null");
+        }
+        if (value.isEmpty()) {
+            throw new IllegalArgumentException(name + " must not be empty");
+        }
+    }
+
+    private static void requireNonNull(String name, String value) {
+        if (value == null) {
+            throw new IllegalArgumentException(name + " must not be null");
+        }
+    }
+
+    private static String dotNetUrlEncode(String raw) {
+        String encoded = URLEncoder.encode(raw, StandardCharsets.UTF_8);
+        // OMG's official appendix uses .NET URL conversion before lowercasing and hashing.
+        return encoded.replace("%2D", "-")
+                .replace("%5F", "_")
+                .replace("%2E", ".")
+                .replace("%21", "!")
+                .replace("%2A", "*")
+                .replace("%28", "(")
+                .replace("%29", ")");
+    }
+
+    private static byte[] sha256(String value) {
+        try {
+            return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+        } catch (NoSuchAlgorithmException exception) {
+            throw new IllegalStateException("SHA-256 not available", exception);
+        }
+    }
+}

+ 19 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgMerchantTradeNoGenerator.java

@@ -0,0 +1,19 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.stereotype.Component;
+
+import java.security.SecureRandom;
+
+@Component
+public class OmgMerchantTradeNoGenerator {
+    private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+    private final SecureRandom random = new SecureRandom();
+
+    public String generate() {
+        StringBuilder value = new StringBuilder("OMG");
+        while (value.length() < 20) {
+            value.append(ALPHABET.charAt(random.nextInt(ALPHABET.length())));
+        }
+        return value.toString();
+    }
+}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyForm.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.omgpay;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Marks a DTO populated from a complete raw OMG server or client POST form. */
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface OmgNotifyForm {
+}

+ 63 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyFormParser.java

@@ -0,0 +1,63 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyField;
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import org.springframework.stereotype.Component;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Strict parser that preserves the original callback body and every actual field. */
+@Component
+public class OmgNotifyFormParser {
+    static final int MAX_FORM_BYTES = 32 * 1024;
+    private static final String FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
+    private static final String OMG_DOCUMENTED_CONTENT_TYPE = "text/html";
+
+    public OmgNotifyRequest parse(byte[] body, String sourceIp, String contentType) {
+        byte[] safeBody = body == null ? new byte[0] : body;
+        String raw = new String(safeBody, StandardCharsets.UTF_8);
+        String normalizedType = contentType == null ? "" : contentType.toLowerCase();
+        if (!normalizedType.startsWith(FORM_CONTENT_TYPE)
+                && !normalizedType.startsWith(OMG_DOCUMENTED_CONTENT_TYPE)) {
+            return invalid(sourceIp, raw, "UNSUPPORTED_CONTENT_TYPE");
+        }
+        if (safeBody.length == 0) {
+            return invalid(sourceIp, raw, "EMPTY_BODY");
+        }
+        if (safeBody.length > MAX_FORM_BYTES) {
+            return invalid(sourceIp, raw, "BODY_TOO_LARGE");
+        }
+        try {
+            return new OmgNotifyRequest(sourceIp, raw, decodeFields(raw), null);
+        } catch (IllegalArgumentException exception) {
+            return invalid(sourceIp, raw, "MALFORMED_FORM");
+        }
+    }
+
+    private static List<OmgNotifyField> decodeFields(String raw) {
+        List<OmgNotifyField> fields = new ArrayList<>();
+        Set<String> names = new HashSet<>();
+        for (String pair : raw.split("&", -1)) {
+            int separator = pair.indexOf('=');
+            if (separator < 1) {
+                throw new IllegalArgumentException("field name/value separator missing");
+            }
+            String name = URLDecoder.decode(pair.substring(0, separator), StandardCharsets.UTF_8);
+            String value = URLDecoder.decode(pair.substring(separator + 1), StandardCharsets.UTF_8);
+            if (name.isEmpty() || !names.add(name)) {
+                throw new IllegalArgumentException("duplicate or empty field name");
+            }
+            fields.add(new OmgNotifyField(name, value));
+        }
+        return fields;
+    }
+
+    static OmgNotifyRequest invalid(String sourceIp, String raw, String reason) {
+        return new OmgNotifyRequest(sourceIp, raw, List.of(), reason);
+    }
+}

+ 85 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilter.java

@@ -0,0 +1,85 @@
+package com.ruoyi.app.omgpay;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+/** Preserves the callback bytes before any interceptor asks the servlet container for form parameters. */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class OmgNotifyRawBodyFilter extends OncePerRequestFilter {
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        String contextPath = request.getContextPath() == null ? "" : request.getContextPath();
+        String path = request.getRequestURI().substring(contextPath.length());
+        return !"POST".equalsIgnoreCase(request.getMethod()) || !("/pay/omg/notify".equals(path)
+                || "/pay/omg/result".equals(path));
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        byte[] body = request.getInputStream().readNBytes(OmgNotifyFormParser.MAX_FORM_BYTES + 1);
+        filterChain.doFilter(new CachedBodyRequest(request, body), response);
+    }
+
+    private static final class CachedBodyRequest extends HttpServletRequestWrapper {
+        private final byte[] body;
+
+        private CachedBodyRequest(HttpServletRequest request, byte[] body) {
+            super(request);
+            this.body = body;
+        }
+
+        @Override
+        public ServletInputStream getInputStream() {
+            ByteArrayInputStream input = new ByteArrayInputStream(body);
+            return new ServletInputStream() {
+                @Override
+                public boolean isFinished() {
+                    return input.available() == 0;
+                }
+
+                @Override
+                public boolean isReady() {
+                    return true;
+                }
+
+                @Override
+                public void setReadListener(ReadListener readListener) {
+                    // Requests are consumed synchronously by the MVC argument resolver.
+                }
+
+                @Override
+                public int read() {
+                    return input.read();
+                }
+            };
+        }
+
+        @Override
+        public BufferedReader getReader() {
+            return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
+        }
+
+        @Override
+        public String getCharacterEncoding() {
+            return StandardCharsets.UTF_8.name();
+        }
+    }
+}

+ 48 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRequestArgumentResolver.java

@@ -0,0 +1,48 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.common.utils.ip.IpUtils;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/** Reads the raw form before MVC can discard unknown or empty callback fields. */
+@Component
+public class OmgNotifyRequestArgumentResolver implements HandlerMethodArgumentResolver {
+    private final OmgNotifyFormParser parser;
+
+    public OmgNotifyRequestArgumentResolver(OmgNotifyFormParser parser) {
+        this.parser = parser;
+    }
+
+    @Override
+    public boolean supportsParameter(MethodParameter parameter) {
+        return OmgNotifyRequest.class.equals(parameter.getParameterType())
+                && parameter.hasParameterAnnotation(OmgNotifyForm.class);
+    }
+
+    @Override
+    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
+                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
+        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
+        if (request == null) {
+            return OmgNotifyFormParser.invalid("unknown", "", "REQUEST_UNAVAILABLE");
+        }
+        String sourceIp = IpUtils.getIpAddr(request);
+        try {
+            byte[] body = request.getInputStream().readNBytes(OmgNotifyFormParser.MAX_FORM_BYTES + 1);
+            return parser.parse(body, sourceIp, request.getContentType());
+        } catch (IOException exception) {
+            return OmgNotifyFormParser.invalid(sourceIp,
+                    "<request-body-read-error:" + exception.getClass().getSimpleName() + ">",
+                    "BODY_READ_FAILED");
+        }
+    }
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyWebMvcConfiguration.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.util.List;
+
+/** Registers the dedicated OMG callback DTO boundary. */
+@Configuration
+public class OmgNotifyWebMvcConfiguration implements WebMvcConfigurer {
+    private final OmgNotifyRequestArgumentResolver resolver;
+
+    public OmgNotifyWebMvcConfiguration(OmgNotifyRequestArgumentResolver resolver) {
+        this.resolver = resolver;
+    }
+
+    @Override
+    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
+        resolvers.add(resolver);
+    }
+}

+ 99 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentAutoCompensationTask.java

@@ -0,0 +1,99 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.List;
+
+/** Periodically queries due CREATED attempts to recover payment callbacks that were lost. */
+@Component
+public class OmgPaymentAutoCompensationTask {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentAutoCompensationTask.class);
+    private static final String LOCK_KEY = "lock:omgpay:automatic-compensation";
+
+    @Value("${omgpay.reconcile.batch-size:20}")
+    int batchSize = 20;
+    @Value("${omgpay.reconcile.retry-delay-ms:300000}")
+    long retryDelayMs = 300000L;
+    @Value("${omgpay.reconcile.round-budget-seconds:45}")
+    long roundBudgetSeconds = 45L;
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgPaymentQueryService queryService;
+    private final RedissonClient redissonClient;
+
+    public OmgPaymentAutoCompensationTask(IOmgPaymentAttemptService attempts,
+                                          OmgPaymentQueryService queryService,
+                                          RedissonClient redissonClient) {
+        this.attempts = attempts;
+        this.queryService = queryService;
+        this.redissonClient = redissonClient;
+    }
+
+    @Scheduled(fixedDelayString = "${omgpay.reconcile.fixed-delay-ms:60000}",
+            initialDelayString = "${omgpay.reconcile.initial-delay-ms:60000}")
+    public void reconcile() {
+        RLock lock = null;
+        try {
+            lock = redissonClient.getLock(LOCK_KEY);
+            if (!lock.tryLock()) {
+                return;
+            }
+            reconcileRound();
+        } catch (Exception exception) {
+            log.error("OMG automatic compensation round failed", exception);
+        } finally {
+            if (lock != null && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    void reconcileRound() {
+        long started = System.currentTimeMillis();
+        Date now = new Date(started);
+        List<OmgPaymentAttempt> dueAttempts = attempts.scanDueCreated(now, batchSize);
+        int reserved = 0;
+        int completed = 0;
+        for (OmgPaymentAttempt attempt : dueAttempts) {
+            if (roundBudgetExceeded(started)) {
+                break;
+            }
+            Date nextQueryTime = new Date(System.currentTimeMillis() + retryDelayMs);
+            if (attempts.reserveDueQuery(attempt.getId(), new Date(), nextQueryTime) != 1) {
+                continue;
+            }
+            reserved++;
+            try {
+                OmgQueryPaymentResponse response = queryService.reconcile(attempt);
+                completed++;
+                log.info("OMG automatic compensation processed attemptId={}, orderId={}, merchantTradeNo={}, "
+                                + "gatewayTradeStatus={}, localStatus={}",
+                        attempt.getId(), OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+                        OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                        response.tradeStatus(), response.status());
+            } catch (Exception exception) {
+                log.error("OMG automatic compensation query failed attemptId={}, orderId={}, merchantTradeNo={}",
+                        attempt.getId(), OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+                        OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception);
+            }
+        }
+        if (!dueAttempts.isEmpty()) {
+            log.info("OMG automatic compensation round completed scanned={}, reserved={}, completed={}",
+                    dueAttempts.size(), reserved, completed);
+        }
+    }
+
+    private boolean roundBudgetExceeded(long started) {
+        return System.currentTimeMillis() - started >= roundBudgetSeconds * 1000L;
+    }
+}

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

@@ -0,0 +1,28 @@
+package com.ruoyi.app.omgpay;
+
+public class OmgPaymentBusinessException extends RuntimeException {
+    private final OmgPaymentErrorCode code;
+    private final Long storeId;
+
+    public OmgPaymentBusinessException(OmgPaymentErrorCode code) {
+        this(code, null);
+    }
+
+    public OmgPaymentBusinessException(OmgPaymentErrorCode code, Long storeId) {
+        super(code.name());
+        this.code = code;
+        this.storeId = storeId;
+    }
+
+    public OmgPaymentErrorCode getCode() {
+        return code;
+    }
+
+    public String getMessageKey() {
+        return code.getMessageKey();
+    }
+
+    public Long getStoreId() {
+        return storeId;
+    }
+}

+ 80 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnService.java

@@ -0,0 +1,80 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/** Verifies OMG browser-return forms and renders navigation only; it never mutates payment state. */
+@Service
+public class OmgPaymentClientReturnService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentClientReturnService.class);
+    private static final List<String> PAYMENT_REQUIRED = List.of(
+            "MerchantID", "MerchantTradeNo", "StoreID", "RtnCode", "RtnMsg", "TradeNo",
+            "TradeAmt", "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate",
+            "SimulatePaid", "CustomField1", "CustomField2", "CustomField3", "CustomField4",
+            "CheckMacValue");
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgCheckMacSigner signer;
+    private final OmgPaymentReturnPageRenderer renderer;
+
+    public OmgPaymentClientReturnService(IOmgPaymentAttemptService attempts,
+                                         OmgCheckMacSigner signer,
+                                         OmgPaymentReturnPageRenderer renderer) {
+        this.attempts = attempts;
+        this.signer = signer;
+        this.renderer = renderer;
+    }
+
+    public OmgPaymentReturnPage paymentResult(OmgNotifyRequest request) {
+        return verifiedReturn(request, PAYMENT_REQUIRED);
+    }
+
+    private OmgPaymentReturnPage verifiedReturn(OmgNotifyRequest request,
+                                                List<String> requiredFields) {
+        String merchantTradeNo = request == null ? null : request.value("MerchantTradeNo");
+        OmgPaymentAttempt attempt = findAttempt(merchantTradeNo);
+        if (request == null || !request.isValid() || attempt == null
+                || !requiredFields.stream().allMatch(request::contains)
+                || !identityMatches(request, attempt) || !signatureMatches(request, attempt)) {
+            log.warn("OMG client return rejected source=payment-result, merchantTradeNo={}, reason=trust_validation_failed",
+                    OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo));
+            return renderer.error(400, "無法驗證返回資料,請回到 App 查詢訂單");
+        }
+        log.info("OMG client return accepted source=payment-result, orderId={}, merchantTradeNo={}, rtnCode={}",
+                OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+                OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo), request.value("RtnCode"));
+        return renderer.success(attempt.getDdId());
+    }
+
+    private boolean signatureMatches(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        String actual = request.value("CheckMacValue");
+        if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) {
+            return false;
+        }
+        try {
+            String expected = signer.sign(request.signingFields(),
+                    attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
+            return OmgPaymentNotifyService.secureEquals(expected, actual);
+        } catch (IllegalArgumentException exception) {
+            return false;
+        }
+    }
+
+    private static boolean identityMatches(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        return attempt.getMerchantId().equals(request.value("MerchantID"))
+                && attempt.getMerchantTradeNo().equals(request.value("MerchantTradeNo"))
+                && String.valueOf(attempt.getAmount()).equals(request.value("TradeAmt"));
+    }
+
+    private OmgPaymentAttempt findAttempt(String merchantTradeNo) {
+        if (merchantTradeNo == null || !merchantTradeNo.matches("[A-Z0-9]{1,20}")) {
+            return null;
+        }
+        return attempts.selectByMerchantTradeNo(merchantTradeNo);
+    }
+}

+ 211 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java

@@ -0,0 +1,211 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentRequest;
+import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
+import com.ruoyi.app.omgpay.dto.OmgRefundPaymentRequest;
+import com.ruoyi.app.omgpay.dto.OmgRetryPaymentRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import java.nio.charset.StandardCharsets;
+
+@RestController
+@RequestMapping("/pay/omg")
+public class OmgPaymentController {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentController.class);
+
+    private final OmgPaymentTokenUserResolver tokenUserResolver;
+    private final OmgPaymentCreateService createService;
+    private final OmgPaymentNotifyService notifyService;
+    private final OmgIpnAuditService ipnAuditService;
+    private final OmgPaymentQueryService queryService;
+    private final OmgPaymentRetryService retryService;
+    private final OmgPaymentRefundService refundService;
+
+    public OmgPaymentController(OmgPaymentTokenUserResolver tokenUserResolver,
+                                OmgPaymentCreateService createService,
+                                OmgPaymentNotifyService notifyService,
+                                OmgIpnAuditService ipnAuditService,
+                                OmgPaymentQueryService queryService,
+                                OmgPaymentRetryService retryService,
+                                OmgPaymentRefundService refundService) {
+        this.tokenUserResolver = tokenUserResolver;
+        this.createService = createService;
+        this.notifyService = notifyService;
+        this.ipnAuditService = ipnAuditService;
+        this.queryService = queryService;
+        this.retryService = retryService;
+        this.refundService = refundService;
+    }
+
+    /** OMG server-to-server final payment notification. */
+    @Anonymous
+    @PostMapping(value = "/notify", produces = MediaType.TEXT_PLAIN_VALUE)
+    public ResponseEntity<String> notify(@OmgNotifyForm OmgNotifyRequest request) {
+        String sourceIp = request == null ? "unknown" : request.getSourceIp();
+        String rawForm = request == null ? "" : request.getRawForm();
+        // Complete callback content is intentionally logged so an operator can copy and replay it.
+        log.info("OMG payment notify received ip={}, rawForm={}", sourceIp, rawForm);
+        try {
+            ipnAuditService.append(sourceIp, rawForm);
+        } catch (Exception auditError) {
+            // Audit storage is best effort and must never cause loss of an otherwise valid payment fact.
+            log.error("OMG payment notify IPN append failed ip={}, rawForm={}",
+                    sourceIp, rawForm, auditError);
+        }
+        try {
+            if (notifyService.process(request)) {
+                return text("1|OK");
+            }
+            log.warn("OMG payment notify rejected ip={}, reason={}", sourceIp,
+                    request == null ? "REQUEST_MISSING" : request.getInvalidReason());
+        } catch (Exception error) {
+            log.error("OMG payment notify transaction failed ip={}, rawForm={}", sourceIp, rawForm, error);
+        }
+        return text("0|ERROR");
+    }
+
+    private static ResponseEntity<String> text(String body) {
+        return ResponseEntity.ok()
+                .contentType(new MediaType("text", "plain", StandardCharsets.UTF_8))
+                .body(body);
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/create")
+    public AjaxResult create(@RequestHeader(name = "token") String token,
+                             @RequestBody(required = false) OmgCreatePaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String paymentMethodValue = request == null ? null : request.getPaymentMethod();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            OmgPaymentMethod paymentMethod = OmgPaymentMethod.require(paymentMethodValue);
+            log.info("OMG payment create started orderId={}, userId={}", safeOrderId, safeUserId);
+            OmgPaymentCreateOutcome outcome = createService.create(safeUserId, orderId, paymentMethod);
+            log.info("OMG payment create succeeded orderId={}, userId={}, storeId={}, "
+                            + "attemptId={}, amount={}, status=CREATED, merchantTradeNo={}",
+                    safeLogOrderId(outcome.orderId()), outcome.userId(), outcome.storeId(), outcome.attemptId(),
+                    outcome.amount(), outcome.maskedMerchantTradeNo());
+            return AjaxResult.success(outcome.response());
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment create rejected orderId={}, userId={}, storeId={}, code={}",
+                    safeOrderId, safeUserId, error.getStoreId(), error.getCode());
+            return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
+                    new OmgPaymentErrorResponse(error.getCode().name()));
+        } catch (Exception error) {
+            log.error("OMG payment create failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.creation.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_CREATION_FAILED"));
+        }
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/query")
+    public AjaxResult query(@RequestHeader(name = "token") String token,
+                            @RequestBody(required = false) OmgQueryPaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            return AjaxResult.success(queryService.query(safeUserId, orderId));
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment query rejected orderId={}, userId={}, storeId={}, code={}",
+                    safeOrderId, safeUserId, error.getStoreId(), error.getCode());
+            return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
+                    new OmgPaymentErrorResponse(error.getCode().name()));
+        } catch (Exception error) {
+            log.error("OMG payment query failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.query.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_QUERY_FAILED"));
+        }
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/retry")
+    public AjaxResult retry(@RequestHeader(name = "token") String token,
+                            @RequestBody(required = false) OmgRetryPaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String paymentMethodValue = request == null ? null : request.getPaymentMethod();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            OmgPaymentMethod paymentMethod = OmgPaymentMethod.require(paymentMethodValue);
+            log.info("OMG payment retry started orderId={}, userId={}", safeOrderId, safeUserId);
+            OmgPaymentCreateOutcome outcome = retryService.retry(safeUserId, orderId, paymentMethod);
+            log.info("OMG payment retry succeeded orderId={}, userId={}, storeId={}, "
+                            + "attemptId={}, amount={}, status=CREATED, merchantTradeNo={}",
+                    safeLogOrderId(outcome.orderId()), outcome.userId(), outcome.storeId(), outcome.attemptId(),
+                    outcome.amount(), outcome.maskedMerchantTradeNo());
+            return AjaxResult.success(outcome.response());
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment retry rejected orderId={}, userId={}, storeId={}, code={}",
+                    safeOrderId, safeUserId, error.getStoreId(), error.getCode());
+            return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
+                    new OmgPaymentErrorResponse(error.getCode().name()));
+        } catch (Exception error) {
+            log.error("OMG payment retry failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.retry.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_RETRY_FAILED"));
+        }
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/refund")
+    public AjaxResult refund(@RequestHeader(name = "token") String token,
+                             @RequestBody(required = false) OmgRefundPaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            refundService.refund(safeUserId, orderId);
+            return AjaxResult.success();
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment refund rejected orderId={}, userId={}, code={}",
+                    safeOrderId, safeUserId, error.getCode());
+            return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
+                    new OmgPaymentErrorResponse(error.getCode().name()));
+        } catch (Exception error) {
+            log.error("OMG payment refund failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.refund.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_REFUND_FAILED"));
+        }
+    }
+
+    static String safeLogOrderId(String orderId) {
+        if (orderId == null) {
+            return "<empty>";
+        }
+        StringBuilder safe = new StringBuilder();
+        for (int index = 0; index < orderId.length() && safe.length() < 64; index++) {
+            char value = orderId.charAt(index);
+            if ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z')
+                    || (value >= '0' && value <= '9') || value == '-' || value == '_') {
+                safe.append(value);
+            }
+        }
+        return safe.isEmpty() ? "<empty>" : safe.toString();
+    }
+}

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateOutcome.java

@@ -0,0 +1,13 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
+
+public record OmgPaymentCreateOutcome(
+        OmgCreatePaymentResponse response,
+        Long attemptId,
+        String orderId,
+        Long userId,
+        Long storeId,
+        Integer amount,
+        String maskedMerchantTradeNo) {
+}

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

@@ -0,0 +1,202 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.order.OrderLifecycleService;
+
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
+import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IPosStoreOmgService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
+
+@Service
+public class OmgPaymentCreateService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentCreateService.class);
+    private static final int MAX_TRADE_NUMBER_ATTEMPTS = 3;
+
+    private final IOmgPaymentAttemptService attempts;
+    private final IPosStoreOmgService credentials;
+    private final OmgMerchantTradeNoGenerator generator;
+    private final OmgPaymentFormFactory formFactory;
+
+    public OmgPaymentCreateService(IOmgPaymentAttemptService attempts,
+                                   IPosStoreOmgService credentials,
+                                   OmgMerchantTradeNoGenerator generator,
+                                   OmgPaymentFormFactory formFactory) {
+        this.attempts = attempts;
+        this.credentials = credentials;
+        this.generator = generator;
+        this.formFactory = formFactory;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public OmgPaymentCreateOutcome create(Long userId, String orderId, OmgPaymentMethod paymentMethod) {
+        requirePaymentMethod(paymentMethod);
+        if (userId == null) {
+            throw business(AUTH_REQUIRED);
+        }
+        String normalizedOrderId = normalizeOrderId(orderId);
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(normalizedOrderId);
+        validateOrder(userId, normalizedOrderId, order);
+        if (attempts.selectActiveCreatedByDdIdForUpdate(normalizedOrderId) != null) {
+            throw business(PAYMENT_ATTEMPT_EXISTS, order.getStoreId());
+        }
+        PosStoreOmg credential = credentials.getEnabledCredential(order.getStoreId());
+        validateCredential(credential, order.getStoreId());
+        log.info("OMG payment validation passed orderId={}, userId={}, storeId={}",
+                safeLogOrderId(order.getDdId()), userId, order.getStoreId());
+        return createWithBoundedTradeNumberRetries(order, userId, credential, paymentMethod);
+    }
+
+    /**
+     * Replaces the exact attempt previously verified as unpaid by the gateway.
+     * The order lock and MerchantTradeNo comparison prevent concurrent retries from both creating a new attempt.
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public OmgPaymentCreateOutcome replaceActiveForRetry(Long userId, String orderId,
+                                                         String expectedMerchantTradeNo,
+                                                         OmgPaymentMethod paymentMethod) {
+        requirePaymentMethod(paymentMethod);
+        if (userId == null) {
+            throw business(AUTH_REQUIRED);
+        }
+        String normalizedOrderId = normalizeOrderId(orderId);
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(normalizedOrderId);
+        validateOrder(userId, normalizedOrderId, order);
+        OmgPaymentAttempt active = attempts.selectActiveCreatedByDdIdForUpdate(normalizedOrderId);
+        if (active == null || active.getId() == null || expectedMerchantTradeNo == null
+                || !expectedMerchantTradeNo.equals(active.getMerchantTradeNo())) {
+            throw business(PAYMENT_RETRY_NOT_AVAILABLE, order.getStoreId());
+        }
+        PosStoreOmg credential = credentials.getEnabledCredential(order.getStoreId());
+        validateCredential(credential, order.getStoreId());
+        if (attempts.supersedeCreated(active.getId()) != 1) {
+            throw business(PAYMENT_RETRY_NOT_AVAILABLE, order.getStoreId());
+        }
+        log.info("OMG payment retry replacing attempt orderId={}, userId={}, storeId={}, oldMerchantTradeNo={}",
+                safeLogOrderId(order.getDdId()), userId, order.getStoreId(),
+                maskMerchantTradeNo(active.getMerchantTradeNo()));
+        return createWithBoundedTradeNumberRetries(order, userId, credential, paymentMethod);
+    }
+
+    private OmgPaymentCreateOutcome createWithBoundedTradeNumberRetries(
+            OmgPaymentOrderSnapshot order, Long userId, PosStoreOmg credential,
+            OmgPaymentMethod paymentMethod) {
+        for (int number = 1; number <= MAX_TRADE_NUMBER_ATTEMPTS; number++) {
+            String merchantTradeNo = generator.generate();
+            OmgPaymentForm form;
+            try {
+                form = formFactory.create(order.getDdId(), order.getAmount(), credential.getMerchantId(),
+                        credential.getHashKey(), credential.getHashIv(), merchantTradeNo, paymentMethod);
+            } catch (IllegalArgumentException error) {
+                throw business(PAYMENT_CONFIGURATION_INVALID, order.getStoreId());
+            }
+            try {
+                OmgPaymentAttempt attempt = attempts.createCreated(order.getDdId(), merchantTradeNo,
+                        order.getStoreId(), credential.getMerchantId(), order.getAmount(),
+                        credential.getHashKey(), credential.getHashIv());
+                OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
+                return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
+                        order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));
+            } catch (DuplicateKeyException error) {
+                if (attempts.selectActiveCreatedByDdIdForUpdate(order.getDdId()) != null) {
+                    throw business(PAYMENT_ATTEMPT_EXISTS, order.getStoreId());
+                }
+                boolean tradeNumberCollision = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo) != null;
+                if (!tradeNumberCollision || number == MAX_TRADE_NUMBER_ATTEMPTS) {
+                    throw business(PAYMENT_CREATION_FAILED, order.getStoreId());
+                }
+            }
+        }
+        throw business(PAYMENT_CREATION_FAILED, order.getStoreId());
+    }
+
+    private static String normalizeOrderId(String orderId) {
+        if (orderId == null || orderId.isBlank()) {
+            throw business(ORDER_REQUIRED);
+        }
+        String normalized = orderId.trim();
+        if (normalized.length() > 64) {
+            throw business(ORDER_REQUIRED);
+        }
+        return normalized;
+    }
+
+    private static void requirePaymentMethod(OmgPaymentMethod paymentMethod) {
+        if (paymentMethod == null) {
+            throw business(PAYMENT_METHOD_INVALID);
+        }
+    }
+
+    private static void validateOrder(Long userId, String orderId, OmgPaymentOrderSnapshot order) {
+        if (order == null || !userId.equals(order.getUserId())) {
+            throw business(ORDER_NOT_AVAILABLE);
+        }
+        Long storeId = order.getStoreId();
+        if (order.getParentDdId() == null || !orderId.equals(order.getParentDdId())) {
+            throw business(MULTI_STORE_ORDER_NOT_SUPPORTED, storeId);
+        }
+        if (storeId == null) {
+            throw business(ORDER_NOT_AVAILABLE);
+        }
+        if (order.getState() == null || order.getState() < 0 || order.getState() > 2) {
+            throw business(ORDER_STATE_NOT_PAYABLE, storeId);
+        }
+        if (order.getPayStatus() == null || order.getPayStatus() != 0) {
+            throw business(ORDER_ALREADY_PAID, storeId);
+        }
+        if (!OrderLifecycleService.isCardWalletPayType(order.getPayType())) {
+            throw business(PAYMENT_TYPE_INVALID, storeId);
+        }
+        if (order.getAmount() == null || order.getAmount() <= 0) {
+            throw business(ORDER_AMOUNT_INVALID, storeId);
+        }
+    }
+
+    private static void validateCredential(PosStoreOmg credential, Long storeId) {
+        if (credential == null || credential.getMerchantId() == null
+                || !credential.getMerchantId().matches("[A-Za-z0-9]{1,10}")
+                || credential.getHashKey() == null || credential.getHashKey().isBlank()
+                || credential.getHashIv() == null || credential.getHashIv().isBlank()) {
+            throw business(STORE_CREDENTIAL_UNAVAILABLE, storeId);
+        }
+    }
+
+    static String maskMerchantTradeNo(String merchantTradeNo) {
+        if (merchantTradeNo == null || merchantTradeNo.length() < 10) {
+            return "***";
+        }
+        return merchantTradeNo.substring(0, 5) + "***"
+                + merchantTradeNo.substring(merchantTradeNo.length() - 4);
+    }
+
+    private static String safeLogOrderId(String orderId) {
+        if (orderId == null) {
+            return "<empty>";
+        }
+        StringBuilder safe = new StringBuilder();
+        for (int index = 0; index < orderId.length() && safe.length() < 64; index++) {
+            char value = orderId.charAt(index);
+            if ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z')
+                    || (value >= '0' && value <= '9') || value == '-' || value == '_') {
+                safe.append(value);
+            }
+        }
+        return safe.isEmpty() ? "<empty>" : safe.toString();
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
+        return new OmgPaymentBusinessException(code);
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code, Long storeId) {
+        return new OmgPaymentBusinessException(code, storeId);
+    }
+}

+ 34 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java

@@ -0,0 +1,34 @@
+package com.ruoyi.app.omgpay;
+
+public enum OmgPaymentErrorCode {
+    AUTH_REQUIRED("omg.pay.auth.required"),
+    ORDER_REQUIRED("omg.pay.order.required"),
+    ORDER_NOT_AVAILABLE("omg.pay.order.not.available"),
+    MULTI_STORE_ORDER_NOT_SUPPORTED("omg.pay.multi.store.unsupported"),
+    ORDER_STATE_NOT_PAYABLE("omg.pay.order.state.not.payable"),
+    ORDER_ALREADY_PAID("omg.pay.order.already.paid"),
+    ORDER_AMOUNT_INVALID("omg.pay.order.amount.invalid"),
+    PAYMENT_TYPE_INVALID("omg.pay.payment.type.invalid"),
+    PAYMENT_METHOD_INVALID("omg.pay.payment.method.invalid"),
+    STORE_CREDENTIAL_UNAVAILABLE("omg.pay.credential.unavailable"),
+    PAYMENT_ATTEMPT_EXISTS("omg.pay.attempt.exists"),
+    PAYMENT_CONFIGURATION_INVALID("omg.pay.configuration.invalid"),
+    PAYMENT_CREATION_FAILED("omg.pay.creation.failed"),
+    PAYMENT_QUERY_NOT_AVAILABLE("omg.pay.query.not.available"),
+    PAYMENT_QUERY_FAILED("omg.pay.query.failed"),
+    PAYMENT_RETRY_NOT_AVAILABLE("omg.pay.retry.not.available"),
+    PAYMENT_RETRY_FAILED("omg.pay.retry.failed"),
+    PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT(
+            "omg.pay.refund.unavailable.test.environment"),
+    PAYMENT_REFUND_FAILED("omg.pay.refund.failed");
+
+    private final String messageKey;
+
+    OmgPaymentErrorCode(String messageKey) {
+        this.messageKey = messageKey;
+    }
+
+    public String getMessageKey() {
+        return messageKey;
+    }
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFactSource.java

@@ -0,0 +1,7 @@
+package com.ruoyi.app.omgpay;
+
+/** Trusted gateway channel from which normalized payment facts originated. */
+public enum OmgPaymentFactSource {
+    NOTIFY,
+    QUERY
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentForm.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.omgpay;
+
+import java.util.Map;
+
+public record OmgPaymentForm(String gatewayUrl, Map<String, String> fields) {
+}

+ 75 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java

@@ -0,0 +1,75 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Component
+public class OmgPaymentFormFactory {
+    public static final String STAGE_GATEWAY_URL =
+            "https://payment-stage.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");
+
+    private final OmgPaymentProperties properties;
+    private final OmgCheckMacSigner signer;
+    private final Clock clock;
+
+    @Autowired
+    public OmgPaymentFormFactory(OmgPaymentProperties properties, OmgCheckMacSigner signer) {
+        this(properties, signer, Clock.systemUTC());
+    }
+
+    OmgPaymentFormFactory(OmgPaymentProperties properties, OmgCheckMacSigner signer, Clock clock) {
+        this.properties = properties;
+        this.signer = signer;
+        this.clock = clock;
+    }
+
+    public OmgPaymentForm create(String orderId, Integer amount, String merchantId,
+                                 String hashKey, String hashIv, String merchantTradeNo,
+                                 OmgPaymentMethod paymentMethod) {
+        String safeOrderId = safeOrderReference(orderId);
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", merchantId);
+        fields.put("MerchantTradeNo", merchantTradeNo);
+        fields.put("MerchantTradeDate", ZonedDateTime.now(clock).withZoneSameInstant(TAIPEI).format(TRADE_DATE));
+        fields.put("PaymentType", "aio");
+        fields.put("TotalAmount", String.valueOf(amount));
+        fields.put("TradeDesc", "Foodie order " + safeOrderId);
+        fields.put("ItemName", "Order " + safeOrderId);
+        fields.put("ReturnURL", properties.requireSafeReturnUrl());
+        fields.put("OrderResultURL", properties.requireSafeOrderResultUrl());
+        fields.put("ChoosePayment", paymentMethod.getChoosePayment());
+        if (paymentMethod.isUnionPayDisabled()) {
+            fields.put("UnionPay", "2");
+        }
+        fields.put("EncryptType", "1");
+        fields.put("InvoiceMark", "N");
+        fields.put("NeedExtraPaidInfo", "Y");
+        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);
+    }
+
+    private static String safeOrderReference(String orderId) {
+        String source = orderId == null ? "" : orderId;
+        StringBuilder safe = new StringBuilder();
+        for (int i = 0; i < source.length() && safe.length() < 64; i++) {
+            char value = source.charAt(i);
+            if ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z')
+                    || (value >= '0' && value <= '9')) {
+                safe.append(value);
+            }
+        }
+        return safe.isEmpty() ? "ORDER" : safe.toString();
+    }
+}

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.omgpay;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** Verified gateway facts shared by callback settlement and query compensation. */
+public record 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) {
+}

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentMethod.java

@@ -0,0 +1,35 @@
+package com.ruoyi.app.omgpay;
+
+/** Maps the App payment-method allowlist to one OMG hosted payment channel. */
+public enum OmgPaymentMethod {
+    CREDIT("Credit", true),
+    APPLE_PAY("ApplePay", false);
+
+    private final String choosePayment;
+    private final boolean unionPayDisabled;
+
+    OmgPaymentMethod(String choosePayment, boolean unionPayDisabled) {
+        this.choosePayment = choosePayment;
+        this.unionPayDisabled = unionPayDisabled;
+    }
+
+    public String getChoosePayment() {
+        return choosePayment;
+    }
+
+    public boolean isUnionPayDisabled() {
+        return unionPayDisabled;
+    }
+
+    public static OmgPaymentMethod require(String value) {
+        try {
+            return value == null ? invalid() : valueOf(value);
+        } catch (IllegalArgumentException error) {
+            return invalid();
+        }
+    }
+
+    private static OmgPaymentMethod invalid() {
+        throw new OmgPaymentBusinessException(OmgPaymentErrorCode.PAYMENT_METHOD_INVALID);
+    }
+}

+ 343 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -0,0 +1,343 @@
+package com.ruoyi.app.omgpay;
+
+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.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IPosOrderService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Date;
+import java.util.List;
+
+/** Verifies and applies one final OMG payment notification transactionally. */
+@Service
+public class OmgPaymentNotifyService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
+    private static final int STATUS_PAID = 1;
+    private static final int STATUS_SUPERSEDED = 3;
+    private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
+    private static final List<String> REQUIRED_FIELDS = List.of(
+            "MerchantID", "MerchantTradeNo", "StoreID", "RtnCode", "RtnMsg", "TradeNo", "TradeAmt",
+            "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate", "SimulatePaid",
+            "CustomField1", "CustomField2", "CustomField3", "CustomField4", "CheckMacValue");
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgCheckMacSigner signer;
+    @Autowired(required = false)
+    private IPosOrderService posOrderService;
+    @Autowired(required = false)
+    private DeliveryOrderNotificationService deliveryOrderNotificationService;
+    @Autowired(required = false)
+    private MerchantNotificationRouter merchantNotificationRouter;
+
+    public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
+        this.attempts = attempts;
+        this.signer = signer;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean process(OmgNotifyRequest request) {
+        if (request == null || !request.isValid() || !hasRequiredFields(request)) {
+            log.warn("OMG notify rejected reason=malformed_or_missing_fields");
+            return false;
+        }
+        String merchantTradeNo = request.value("MerchantTradeNo");
+        if (isBlank(merchantTradeNo) || merchantTradeNo.length() > 20) {
+            log.warn("OMG notify rejected reason=invalid_merchant_trade_no");
+            return false;
+        }
+
+        OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(merchantTradeNo);
+        if (discovered == null) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=attempt_not_found", merchantTradeNo);
+            return false;
+        }
+        if (factsDoNotIdentifySameAttempt(request, discovered)) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=prelock_fact_mismatch", merchantTradeNo);
+            return false;
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
+        OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo);
+        if (attempt == null || !verifyTrust(request, attempt)) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=trust_validation_failed", merchantTradeNo);
+            return false;
+        }
+
+        ParsedFacts facts;
+        try {
+            facts = parseFacts(request);
+        } catch (IllegalArgumentException exception) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=invalid_gateway_facts", merchantTradeNo);
+            return false;
+        }
+        OmgPaymentGatewayFacts gatewayFacts = new OmgPaymentGatewayFacts(
+                OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
+                attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
+                facts.paymentType, facts.paymentDate, facts.tradeDate,
+                facts.paymentTypeChargeFee, facts.simulatePaid);
+        return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
+    }
+
+    /**
+     * Applies a signed QueryTradeInfo result so a lost callback cannot leave a paid order unpaid.
+     * The row locks and irreversible state rules are identical to the callback path.
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public OmgPaymentSettlementResult synchronizeVerifiedQuery(OmgPaymentGatewayFacts facts) {
+        if (facts == null || facts.source() != OmgPaymentFactSource.QUERY) {
+            throw new IllegalArgumentException("verified OMG query facts are required");
+        }
+        OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(facts.merchantTradeNo());
+        if (discovered == null || !sameIdentity(facts, discovered)) {
+            throw new IllegalArgumentException("OMG query facts do not identify an attempt");
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
+        OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(facts.merchantTradeNo());
+        if (attempt == null || !sameIdentity(facts, attempt)) {
+            throw new IllegalArgumentException("OMG query attempt changed while locking");
+        }
+        return applyVerifiedFacts(order, attempt, facts);
+    }
+
+    private OmgPaymentSettlementResult applyVerifiedFacts(OmgPaymentOrderSnapshot order,
+                                                          OmgPaymentAttempt attempt,
+                                                          OmgPaymentGatewayFacts facts) {
+        boolean paid = facts.resultCode() == 1;
+        if (!paid) {
+            if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
+                log.warn("OMG {} ignored late failure merchantTradeNo={}, code={}, message={}",
+                        facts.source(), attempt.getMerchantTradeNo(), facts.resultCode(), facts.resultMessage());
+                return OmgPaymentSettlementResult.PAID;
+            }
+            if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_SUPERSEDED) {
+                log.warn("OMG {} accepted failure for superseded attempt merchantTradeNo={}, code={}",
+                        facts.source(), attempt.getMerchantTradeNo(), facts.resultCode());
+                return OmgPaymentSettlementResult.FAILED;
+            }
+            requireSingleUpdate(attempts.markFailed(toUpdate(attempt, facts)), "mark failed");
+            log.warn("OMG payment failed source={}, merchantTradeNo={}, tradeNo={}, code={}, message={}",
+                    facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo(),
+                    facts.resultCode(), facts.resultMessage());
+            return OmgPaymentSettlementResult.FAILED;
+        }
+
+        if (order == null) {
+            throw new IllegalStateException("OMG paid fact has no order");
+        }
+        if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
+            if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo())) {
+                throw new IllegalStateException("OMG paid fact conflicts with stored TradeNo");
+            }
+            log.info("OMG paid duplicate accepted source={}, merchantTradeNo={}, tradeNo={}",
+                    facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo());
+            return OmgPaymentSettlementResult.PAID;
+        }
+
+        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());
+        }
+        int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
+        if (order.getState() != null && order.getState() == 4L) {
+            log.error("OMG paid after order cancellation source={}, orderId={}, merchantTradeNo={}, tradeNo={}",
+                    facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo());
+        }
+        if (otherPaid > 0) {
+            log.error("OMG multiple paid attempts source={}, orderId={}, merchantTradeNo={}, "
+                            + "tradeNo={}, otherPaidCount={}",
+                    facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
+                    facts.tradeNo(), otherPaid);
+        }
+        log.info("OMG payment marked paid source={}, orderId={}, merchantTradeNo={}, tradeNo={}, amount={}",
+                facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
+                facts.tradeNo(), attempt.getAmount());
+        return OmgPaymentSettlementResult.PAID;
+    }
+
+    private void openDeliveryOrderToRiders(String ddId, Long state) {
+        if (Long.valueOf(4L).equals(state) || posOrderService == null
+                || (deliveryOrderNotificationService == null && merchantNotificationRouter == null)) {
+            return;
+        }
+        PosOrder paidOrder = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getDdId, ddId));
+        if (paidOrder == null) {
+            return;
+        }
+        paidOrder.setPayStatus(1L);
+        if (Long.valueOf(0L).equals(paidOrder.getType()) && deliveryOrderNotificationService != null) {
+            deliveryOrderNotificationService.notifyOrderAvailable(paidOrder);
+        } else if (merchantNotificationRouter != null) {
+            String orderNo = String.valueOf(paidOrder.getDdId());
+            String body = OrderPushBodyDto.getJson(orderNo, String.valueOf(paidOrder.getState()), 0);
+            merchantNotificationRouter.sendStoreNotification(paidOrder.getMdId(), paidOrder.getShId(),
+                    "no.message.push.message", "no.message.push.new.order", body, orderNo);
+        }
+    }
+
+    private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
+            return false;
+        }
+        Integer amount = parseInteger(request.value("TradeAmt"));
+        if (amount == null || !amount.equals(attempt.getAmount())) {
+            return false;
+        }
+        String actual = request.value("CheckMacValue");
+        if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) {
+            return false;
+        }
+        String expected = signer.sign(request.signingFields(),
+                attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
+        return secureEquals(expected, actual);
+    }
+
+    private static boolean factsDoNotIdentifySameAttempt(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        Integer amount = parseInteger(request.value("TradeAmt"));
+        return !request.value("MerchantID").equals(attempt.getMerchantId())
+                || amount == null || !amount.equals(attempt.getAmount());
+    }
+
+    private static boolean hasRequiredFields(OmgNotifyRequest request) {
+        return REQUIRED_FIELDS.stream().allMatch(request::contains);
+    }
+
+    private static ParsedFacts parseFacts(OmgNotifyRequest request) {
+        Integer rtnCode = requiredInteger(request.value("RtnCode"));
+        BigDecimal fee = requiredDecimal(request.value("PaymentTypeChargeFee"));
+        if (fee.signum() < 0) {
+            throw new IllegalArgumentException("invalid PaymentTypeChargeFee");
+        }
+        Integer simulatePaid = requiredInteger(request.value("SimulatePaid"));
+        if (simulatePaid != 0 && simulatePaid != 1) {
+            throw new IllegalArgumentException("invalid SimulatePaid");
+        }
+        String tradeNo = request.value("TradeNo");
+        if ((rtnCode == 1 && isBlank(tradeNo)) || (tradeNo != null && tradeNo.length() > 20)) {
+            throw new IllegalArgumentException("invalid TradeNo");
+        }
+        if (isBlank(tradeNo)) {
+            tradeNo = null;
+        }
+        String paymentType = request.value("PaymentType");
+        if (isBlank(paymentType) || paymentType.length() > 20) {
+            throw new IllegalArgumentException("invalid PaymentType");
+        }
+        Date paymentDate = parseDate(request.value("PaymentDate"), rtnCode == 1);
+        Date tradeDate = parseDate(request.value("TradeDate"), true);
+        String rtnMsg = request.value("RtnMsg");
+        if (rtnMsg == null || rtnMsg.length() > 200) {
+            throw new IllegalArgumentException("invalid RtnMsg");
+        }
+        return new ParsedFacts(rtnCode, rtnMsg, tradeNo, paymentType,
+                paymentDate, tradeDate, fee, simulatePaid);
+    }
+
+    private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, OmgPaymentGatewayFacts facts) {
+        OmgPaymentAttempt update = new OmgPaymentAttempt();
+        update.setId(attempt.getId());
+        update.setTradeNo(facts.tradeNo());
+        update.setRtnCode(facts.resultCode());
+        update.setRtnMsg(limit(facts.resultMessage(), 200));
+        update.setPaymentType(limit(facts.paymentType(), 20));
+        update.setPaymentDate(facts.paymentDate());
+        update.setTradeDate(facts.tradeDate());
+        update.setPaymentTypeChargeFee(facts.paymentTypeChargeFee());
+        update.setSimulatePaid(facts.simulatePaid());
+        update.setLastNotifyTime(new Date());
+        update.setUpdateTime(new Date());
+        return update;
+    }
+
+    private static boolean sameIdentity(OmgPaymentGatewayFacts facts, OmgPaymentAttempt attempt) {
+        return facts.merchantTradeNo().equals(attempt.getMerchantTradeNo())
+                && facts.merchantId().equals(attempt.getMerchantId())
+                && facts.amount() == attempt.getAmount();
+    }
+
+    static boolean secureEquals(String expected, String actual) {
+        return expected != null && actual != null
+                && MessageDigest.isEqual(expected.toUpperCase().getBytes(StandardCharsets.US_ASCII),
+                actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
+    }
+
+    private static Date parseDate(String value, boolean required) {
+        if (isBlank(value)) {
+            if (required) {
+                throw new IllegalArgumentException("required date missing");
+            }
+            return null;
+        }
+        try {
+            return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant());
+        } catch (DateTimeParseException exception) {
+            throw new IllegalArgumentException("invalid date", exception);
+        }
+    }
+
+    private static Integer requiredInteger(String value) {
+        Integer parsed = parseInteger(value);
+        if (parsed == null) {
+            throw new IllegalArgumentException("invalid integer");
+        }
+        return parsed;
+    }
+
+    private static BigDecimal requiredDecimal(String value) {
+        if (isBlank(value)) {
+            throw new IllegalArgumentException("invalid decimal");
+        }
+        try {
+            return new BigDecimal(value);
+        } catch (NumberFormatException exception) {
+            throw new IllegalArgumentException("invalid decimal", exception);
+        }
+    }
+
+    private static Integer parseInteger(String value) {
+        try {
+            return isBlank(value) ? null : Integer.valueOf(value);
+        } catch (NumberFormatException exception) {
+            return null;
+        }
+    }
+
+    private static void requireSingleUpdate(int count, String action) {
+        if (count != 1) {
+            throw new IllegalStateException("OMG notify failed to " + action);
+        }
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+
+    private static String limit(String value, int length) {
+        return value == null || value.length() <= length ? value : value.substring(0, length);
+    }
+
+    private record ParsedFacts(int rtnCode, String rtnMsg, String tradeNo, String paymentType,
+                               Date paymentDate, Date tradeDate,
+                               BigDecimal paymentTypeChargeFee, int simulatePaid) {
+    }
+}

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

@@ -0,0 +1,98 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+
+@Component
+@ConfigurationProperties(prefix = "omgpay")
+public class OmgPaymentProperties {
+    private static final String APP_RETURN_SCHEME = "com.twanmsdyh.app";
+    private static final String APP_RETURN_HOST = "pages";
+    private static final String APP_RETURN_PATH = "/OrderList/paySuccess/paySuccess";
+    private String returnUrl;
+    private String orderResultUrl;
+    private String appReturnUrl;
+
+    public String getReturnUrl() {
+        return returnUrl;
+    }
+
+    public void setReturnUrl(String returnUrl) {
+        this.returnUrl = returnUrl;
+    }
+
+    public String getOrderResultUrl() {
+        return orderResultUrl;
+    }
+
+    public void setOrderResultUrl(String orderResultUrl) {
+        this.orderResultUrl = orderResultUrl;
+    }
+
+    public String getAppReturnUrl() {
+        return appReturnUrl;
+    }
+
+    public void setAppReturnUrl(String appReturnUrl) {
+        this.appReturnUrl = appReturnUrl;
+    }
+
+    public String requireSafeReturnUrl() {
+        return requireSafeCallbackUrl(returnUrl, "/pay/omg/notify", "ReturnURL");
+    }
+
+    public String requireSafeOrderResultUrl() {
+        return requireSafeCallbackUrl(orderResultUrl, "/pay/omg/result", "OrderResultURL");
+    }
+
+    public String safeAppReturnUrlOrNull() {
+        if (appReturnUrl == null || appReturnUrl.isBlank()) {
+            return null;
+        }
+        URI uri;
+        try {
+            uri = URI.create(appReturnUrl.trim());
+        } catch (IllegalArgumentException error) {
+            throw new IllegalArgumentException("App return URL is invalid", error);
+        }
+        if (!APP_RETURN_SCHEME.equalsIgnoreCase(uri.getScheme())
+                || !APP_RETURN_HOST.equals(uri.getHost())
+                || !APP_RETURN_PATH.equals(uri.getPath())
+                || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) {
+            throw new IllegalArgumentException("App return URL is unsafe");
+        }
+        return uri.toASCIIString();
+    }
+
+    private static String requireSafeCallbackUrl(String value, String expectedPath, String name) {
+        URI uri = requireSafeHttpsUrl(value, "OMG " + name, true);
+        if (!expectedPath.equals(uri.getPath())) {
+            throw new IllegalArgumentException("OMG " + name + " is unsafe");
+        }
+        return uri.toASCIIString();
+    }
+
+    private static URI requireSafeHttpsUrl(String value, String name, boolean rejectRootPath) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(name + " is required");
+        }
+        URI uri;
+        try {
+            uri = URI.create(value.trim());
+        } catch (IllegalArgumentException error) {
+            throw new IllegalArgumentException(name + " is invalid", error);
+        }
+        if (value.trim().length() > 200) {
+            throw new IllegalArgumentException(name + " exceeds 200 characters");
+        }
+        if (!uri.isAbsolute() || !"https".equalsIgnoreCase(uri.getScheme())
+                || uri.getHost() == null || uri.getHost().isBlank()
+                || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
+                || (rejectRootPath && (uri.getPath() == null || "/".equals(uri.getPath())))) {
+            throw new IllegalArgumentException(name + " is unsafe");
+        }
+        return uri;
+    }
+}

+ 56 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java

@@ -0,0 +1,56 @@
+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;
+
+/** Stage-only HTTP boundary for OMG QueryTradeInfo V5. */
+@Component
+public class OmgPaymentQueryGateway {
+    static final String STAGE_QUERY_URL =
+            "https://payment-stage.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)
+            .setConnectionRequestTimeout(5_000)
+            .setSocketTimeout(10_000)
+            .build();
+
+    public String query(Map<String, String> fields) {
+        List<NameValuePair> form = new ArrayList<>();
+        fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
+        HttpPost post = new HttpPost(STAGE_QUERY_URL);
+        post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.custom()
+                .setDefaultRequestConfig(REQUEST_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 query returned an invalid HTTP response");
+            }
+            byte[] bytes = EntityUtils.toByteArray(response.getEntity());
+            if (bytes.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG query response is oversized");
+            }
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (IOException exception) {
+            throw new IllegalStateException("OMG query request failed", exception);
+        }
+    }
+}

+ 299 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -0,0 +1,299 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+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.stereotype.Service;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
+
+/** Queries the one current payment attempt and compensates for a lost paid callback. */
+@Service
+public class OmgPaymentQueryService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentQueryService.class);
+    private static final int ATTEMPT_PAID = 1;
+    private static final String TRADE_UNPAID = "0";
+    private static final String TRADE_PAID = "1";
+    private static final String TRADE_FAILED = "10200095";
+    private static final String TRADE_NOT_FOUND = "10200047";
+    private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
+    private static final List<String> CORE_REQUIRED_FIELDS = List.of(
+            "MerchantID", "MerchantTradeNo", "TradeAmt", "TradeStatus", "CheckMacValue");
+    private static final List<String> PAID_REQUIRED_FIELDS = List.of(
+            "TradeNo", "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate");
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgPaymentQueryGateway gateway;
+    private final OmgQueryResponseParser parser;
+    private final OmgCheckMacSigner signer;
+    private final OmgPaymentNotifyService settlement;
+    private final Clock clock;
+
+    @Autowired
+    public OmgPaymentQueryService(IOmgPaymentAttemptService attempts,
+                                  OmgPaymentQueryGateway gateway,
+                                  OmgQueryResponseParser parser,
+                                  OmgCheckMacSigner signer,
+                                  OmgPaymentNotifyService settlement) {
+        this(attempts, gateway, parser, signer, settlement, Clock.systemUTC());
+    }
+
+    OmgPaymentQueryService(IOmgPaymentAttemptService attempts,
+                           OmgPaymentQueryGateway gateway,
+                           OmgQueryResponseParser parser,
+                           OmgCheckMacSigner signer,
+                           OmgPaymentNotifyService settlement,
+                           Clock clock) {
+        this.attempts = attempts;
+        this.gateway = gateway;
+        this.parser = parser;
+        this.signer = signer;
+        this.settlement = settlement;
+        this.clock = clock;
+    }
+
+    public OmgQueryPaymentResponse query(Long userId, String orderId) {
+        if (userId == null) {
+            throw business(AUTH_REQUIRED);
+        }
+        String normalizedOrderId = normalizeOrderId(orderId);
+        OmgPaymentOrderSnapshot order = attempts.selectOrder(normalizedOrderId);
+        if (order == null || !userId.equals(order.getUserId())) {
+            throw business(ORDER_NOT_AVAILABLE);
+        }
+        OmgPaymentAttempt attempt = selectCurrentAttempt(order);
+        validateAttempt(attempt, order.getStoreId());
+        log.info("OMG payment query started orderId={}, userId={}, storeId={}, merchantTradeNo={}",
+                OmgPaymentController.safeLogOrderId(order.getDdId()), userId, order.getStoreId(),
+                OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()));
+        return queryAttempt(attempt, order.getDdId(), order.getStoreId(), "USER");
+    }
+
+    /** Reuses the verified query and settlement path for one scheduler-reserved CREATED attempt. */
+    public OmgQueryPaymentResponse reconcile(OmgPaymentAttempt attempt) {
+        Long storeId = attempt == null ? null : attempt.getStoreId();
+        validateAttempt(attempt, storeId);
+        log.info("OMG automatic compensation query started orderId={}, storeId={}, merchantTradeNo={}, queryCount={}",
+                OmgPaymentController.safeLogOrderId(attempt.getDdId()), storeId,
+                OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                attempt.getQueryCount());
+        return queryAttempt(attempt, attempt.getDdId(), storeId, "AUTO");
+    }
+
+    private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId,
+                                                  Long storeId, String source) {
+        try {
+            Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
+            validateResponse(response, attempt);
+            String tradeStatus = response.get("TradeStatus");
+            String status = synchronizeIfFinal(tradeStatus, response, attempt);
+            log.info("OMG payment query completed source={}, orderId={}, merchantTradeNo={}, tradeNo={}, "
+                            + "gatewayTradeStatus={}, localStatus={}",
+                    source, OmgPaymentController.safeLogOrderId(orderId),
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                    maskTradeNo(response.get("TradeNo")), tradeStatus, status);
+            return toResponse(status, response);
+        } catch (OmgPaymentBusinessException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            log.error("OMG payment query failed source={}, orderId={}, merchantTradeNo={}",
+                    source, OmgPaymentController.safeLogOrderId(orderId),
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception);
+            throw business(PAYMENT_QUERY_FAILED, storeId);
+        }
+    }
+
+    private OmgPaymentAttempt selectCurrentAttempt(OmgPaymentOrderSnapshot order) {
+        if (order.getPayStatus() != null && order.getPayStatus() == 1L) {
+            return attempts.selectPaidByDdId(order.getDdId());
+        }
+        return attempts.selectActiveCreatedByDdId(order.getDdId());
+    }
+
+    private Map<String, String> buildRequest(OmgPaymentAttempt attempt) {
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("MerchantTradeNo", attempt.getMerchantTradeNo());
+        fields.put("TimeStamp", String.valueOf(clock.instant().getEpochSecond()));
+        fields.put("CheckMacValue", signer.sign(fields,
+                attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        return fields;
+    }
+
+    private void validateResponse(Map<String, String> response, OmgPaymentAttempt attempt) {
+        List<String> missingCoreFields = missingOrBlankFields(response, CORE_REQUIRED_FIELDS);
+        if (!missingCoreFields.isEmpty()) {
+            log.warn("OMG query response rejected merchantTradeNo={} reason=missing_core_fields fields={}",
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                    missingCoreFields);
+            throw new IllegalArgumentException("OMG query response has missing fields");
+        }
+        String checkMacValue = response.get("CheckMacValue");
+        if (checkMacValue == null || !checkMacValue.matches("(?i)[0-9a-f]{64}")) {
+            throw new IllegalArgumentException("OMG query response signature is malformed");
+        }
+        LinkedHashMap<String, String> signingFields = new LinkedHashMap<>(response);
+        signingFields.remove("CheckMacValue");
+        String expected = signer.sign(signingFields,
+                attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
+        if (!OmgPaymentNotifyService.secureEquals(expected, checkMacValue)) {
+            throw new IllegalArgumentException("OMG query response signature mismatch");
+        }
+        String tradeStatus = response.get("TradeStatus");
+        String expectedAmount = TRADE_NOT_FOUND.equals(tradeStatus)
+                ? "0" : String.valueOf(attempt.getAmount());
+        if (!attempt.getMerchantId().equals(response.get("MerchantID"))
+                || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
+                || !expectedAmount.equals(response.get("TradeAmt"))) {
+            throw new IllegalArgumentException("OMG query response identity mismatch");
+        }
+        if (TRADE_PAID.equals(tradeStatus)) {
+            List<String> missingPaidFields = missingOrBlankFields(response, PAID_REQUIRED_FIELDS);
+            if (!missingPaidFields.isEmpty()) {
+                log.warn("OMG paid query response rejected merchantTradeNo={} "
+                                + "reason=missing_settlement_fields fields={}",
+                        OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                        missingPaidFields);
+                throw new IllegalArgumentException("OMG paid query response has missing fields");
+            }
+        }
+    }
+
+    private static List<String> missingOrBlankFields(Map<String, String> response,
+                                                     List<String> requiredFields) {
+        return requiredFields.stream()
+                .filter(field -> isBlank(response.get(field)))
+                .toList();
+    }
+
+    private String synchronizeIfFinal(String tradeStatus, Map<String, String> fields,
+                                      OmgPaymentAttempt attempt) {
+        if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == ATTEMPT_PAID
+                && !TRADE_PAID.equals(tradeStatus)) {
+            log.error("OMG query cannot downgrade paid attempt merchantTradeNo={}, gatewayTradeStatus={}",
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), tradeStatus);
+            return "PAID";
+        }
+        if (TRADE_UNPAID.equals(tradeStatus)) {
+            return "UNPAID";
+        }
+        if (!TRADE_PAID.equals(tradeStatus)
+                && !TRADE_FAILED.equals(tradeStatus)
+                && !TRADE_NOT_FOUND.equals(tradeStatus)) {
+            return "UNKNOWN";
+        }
+        OmgPaymentSettlementResult result = settlement.synchronizeVerifiedQuery(
+                toGatewayFacts(fields, attempt, Integer.parseInt(tradeStatus)));
+        return result.name();
+    }
+
+    private static OmgPaymentGatewayFacts toGatewayFacts(Map<String, String> fields,
+                                                         OmgPaymentAttempt attempt,
+                                                         int tradeStatus) {
+        boolean paid = tradeStatus == 1;
+        String tradeNo = blankToNull(fields.get("TradeNo"));
+        if (paid && tradeNo == null) {
+            throw new IllegalArgumentException("paid query response has no TradeNo");
+        }
+        String paymentType = blankToNull(fields.get("PaymentType"));
+        if (paid && paymentType == null) {
+            throw new IllegalArgumentException("paid query response has no PaymentType");
+        }
+        BigDecimal fee = parseDecimal(fields.get("PaymentTypeChargeFee"));
+        if ((paid && fee == null) || (fee != null && fee.signum() < 0)) {
+            throw new IllegalArgumentException("invalid payment fee");
+        }
+        return new OmgPaymentGatewayFacts(OmgPaymentFactSource.QUERY,
+                attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
+                tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
+                parseDate(fields.get("PaymentDate"), paid),
+                parseDate(fields.get("TradeDate"), paid), fee, null);
+    }
+
+    private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {
+        return new OmgQueryPaymentResponse(status, fields.get("TradeStatus"),
+                fields.get("MerchantTradeNo"), blankToNull(fields.get("TradeNo")),
+                Integer.valueOf(fields.get("TradeAmt")), blankToNull(fields.get("PaymentDate")),
+                blankToNull(fields.get("TradeDate")), blankToNull(fields.get("PaymentType")),
+                fields.get("HandlingCharge"), fields.get("PaymentTypeChargeFee"));
+    }
+
+    private static void validateAttempt(OmgPaymentAttempt attempt, Long storeId) {
+        if (attempt == null) {
+            throw business(PAYMENT_QUERY_NOT_AVAILABLE, storeId);
+        }
+        if (isBlank(attempt.getMerchantId()) || isBlank(attempt.getMerchantTradeNo())
+                || attempt.getAmount() == null || attempt.getAmount() <= 0
+                || isBlank(attempt.getHashKeySnapshot()) || isBlank(attempt.getHashIvSnapshot())) {
+            throw business(PAYMENT_QUERY_FAILED, storeId);
+        }
+    }
+
+    private static String normalizeOrderId(String orderId) {
+        if (isBlank(orderId) || orderId.trim().length() > 64) {
+            throw business(ORDER_REQUIRED);
+        }
+        return orderId.trim();
+    }
+
+    private static Date parseDate(String value, boolean required) {
+        if (isBlank(value)) {
+            if (required) {
+                throw new IllegalArgumentException("required OMG date is missing");
+            }
+            return null;
+        }
+        try {
+            return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant());
+        } catch (DateTimeParseException exception) {
+            throw new IllegalArgumentException("invalid OMG date", exception);
+        }
+    }
+
+    private static BigDecimal parseDecimal(String value) {
+        try {
+            return isBlank(value) ? null : new BigDecimal(value);
+        } catch (NumberFormatException exception) {
+            throw new IllegalArgumentException("invalid OMG decimal", exception);
+        }
+    }
+
+    private static String blankToNull(String value) {
+        return isBlank(value) ? null : value;
+    }
+
+    private static String maskTradeNo(String tradeNo) {
+        if (isBlank(tradeNo)) {
+            return "<empty>";
+        }
+        return tradeNo.length() <= 6 ? "***" : "***" + tradeNo.substring(tradeNo.length() - 6);
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
+        return new OmgPaymentBusinessException(code);
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code, Long storeId) {
+        return new OmgPaymentBusinessException(code, storeId);
+    }
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików