Browse Source

提交剩余功能调整与测试资料

完善 OAuth 登录、手机号绑定、商家账号及摊位访问逻辑。
补充优惠券优惠上限自动化测试和相关规格记录。
一并提交当前工作区中的 SQL、国际航班规格及蓝湖分析验证资料。
qmj 40 phút trước từ bây giờ
mục cha
commit
af8cc41563
45 tập tin đã thay đổi với 2060 bổ sung221 xóa
  1. 3 0
      .claude/homunculus/observations.jsonl
  2. 34 0
      .tmp/lanhu-flash-delivery/analysis.json
  3. BIN
      .tmp/lanhu-flash-delivery/mcp-01.png
  4. BIN
      .tmp/lanhu-flash-delivery/mcp-02.png
  5. BIN
      .tmp/lanhu-flash-delivery/mcp-03.png
  6. BIN
      .tmp/lanhu-flash-delivery/mcp-04.png
  7. BIN
      .tmp/lanhu-flash-delivery/mcp-05.png
  8. BIN
      .tmp/lanhu-flash-delivery/mcp-06.png
  9. BIN
      .tmp/lanhu-flash-delivery/mcp-07.png
  10. 49 0
      .tmp/lanhu-mcp-read.mjs
  11. 21 0
      .tmp/lanhu-summarize.mjs
  12. 30 0
      .tmp/verify-flash-spec.mjs
  13. 1 0
      .video_agent/plugin_root
  14. 13 10
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java
  15. 40 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/BusinessPhoneService.java
  16. 127 21
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  17. 64 76
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  18. 8 4
      ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountApplicationService.java
  19. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java
  20. 132 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/LineOAuthProperties.java
  21. 32 49
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java
  22. 17 8
      ruoyi-admin/src/main/resources/application.yml
  23. 4 1
      ruoyi-admin/src/main/resources/i18n/messages.properties
  24. 4 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  25. 4 1
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  26. 4 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  27. 4 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  28. 72 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/StallControllerTest.java
  29. 68 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/BusinessPhoneServiceTest.java
  30. 241 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java
  31. 213 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java
  32. 58 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSubaccountApplicationServiceTest.java
  33. 84 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/LineOAuthPropertiesTest.java
  34. 1 1
      ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/LoginUserDto.java
  35. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/utils/JwtUtil.java
  36. 307 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplDiscountLimitTest.java
  37. 100 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImplDiscountLimitTest.java
  38. 162 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionDiscountLimitCheckerTest.java
  39. 3 0
      specs/008-promotion-coupon/tasks.md
  40. 5 0
      specs/015-intl-flight/spec.md
  41. 32 19
      specs/017-oauth-login/line-callback-frontend.md
  42. 40 5
      specs/017-oauth-login/plan.md
  43. 10 4
      specs/017-oauth-login/spec.md
  44. 35 16
      specs/017-oauth-login/tasks.md
  45. 36 1
      updatesql/sql.md

+ 3 - 0
.claude/homunculus/observations.jsonl

@@ -9756,3 +9756,6 @@ ATE TABLE pos_order_invoice (\\n236:) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMM
 {"timestamp":"2026-09-03T10:01:05Z","event":"parse_error","raw":"{\"session_id\":\"01b4b2c0-b6f1-405c-975f-7c995ab50aa5\",\"transcript_path\":\"C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\01b4b2c0-b6f1-405c-975f-7c995ab50aa5.jsonl\",\"cwd\":\"E:\\\\QtwCode\\\\foodie\\\\foodie_server\",\"prompt_id\":\"c6c0c369-49b8-4d5c-8e97-65d90bbe7b83\",\"permission_mode\":\"bypassPermissions\",\"effort\":{\"level\":\"max\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"cd \\\"E:/QtwCode/foodie/foodie_server\\\" && python << 'PYEOF'\\nimport io\\np = 'specs/023-payment-provider-routing/spec.md'\\nc = io.open(p, 'r', encoding='utf-8', newline='').read()\\neol = '\\\\r\\\\n' if '\\\\r\\\\n' in c else '\\\\n'\\nold = '**\u9418\u8235\udc80\udc81**\u951b\u6c33\udcae\u6350\udcae\u00a4\udcae\u3128\udcae\u70d8\u6b8f\u934b\u6ed0\u7d31\u5bb8\u8336\udcae\u677f\u7d8d\u7ead\udcae\u7481\u3089\u300d\u951b\u5c7d\u76bb\u93c8\udcaa\u6769\u6d98\u53c6 plan\u9286\u4e7casks \u93b4\udc96 implement'\\nassert c.count(old) == 1\\nnew = old + eol + eol + '**2026-09-03 \u95ae\u3125\u578e\u9480\u85c9\u6e74**\u951b\u6b30pple Pay\u951b\u5767ayType=5\u951b\u590c\u568e\u6d93\u5a45\u4ec8\u748b\u51ad\udca2\udcab OMG \u9352\u6d98\u7f13\u93cd\uffe0\u7359\u93b7\u6394\u7cb7\u951b\u581d\u5f67\u7481\udca4 payType=2\u951b\u5908\udc80\u509b\u5bdc\u93c8\udcac\u7459\u52ec\u7278\u5bb8\u832c\u2018\u7481\u3087\u6b91\u9286\u5c7c\u4fca\u9422\u3125\u5d31\u6d93\udc8e Apple Pay \u935a\u5c80\u7c8d\u935a\u5c7d\u6662\u9286\u5d88\udcaf\udcad\u6d94\u591b\u7d1d\u934f\u5823\u60e4\u9366\u7248\u7b2d\u95ac\u64b3\u7d8a\u7f01\u52eb\u57bd\u93c2\udcad `OrderLifecycleService.isCardWalletPayType`\u951b\udc882 \u93b4\udc96 5 \u9367\u56ea\u86cb OMG\u951b\u591b\u7d1d\u93c7\u630e\u5d32\u6d5c\udc86 OMG \u9352\u6d98\u7f13\u93cd\uffe0\u7359\u9286\u4f79\u5e34\u9357\u66df\u656e\u6d60\u6a40\u68ec\u59b2\u6d96\udc80\u4f7a\u59f8"}
 {"timestamp":"2026-09-03T10:01:06Z","event":"parse_error","raw":"{\"session_id\":\"01b4b2c0-b6f1-405c-975f-7c995ab50aa5\",\"transcript_path\":\"C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\01b4b2c0-b6f1-405c-975f-7c995ab50aa5.jsonl\",\"cwd\":\"E:\\\\QtwCode\\\\foodie\\\\foodie_server\",\"prompt_id\":\"c6c0c369-49b8-4d5c-8e97-65d90bbe7b83\",\"permission_mode\":\"bypassPermissions\",\"effort\":{\"level\":\"max\"},\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"cd \\\"E:/QtwCode/foodie/foodie_server\\\" && python << 'PYEOF'\\nimport io\\np = 'specs/023-payment-provider-routing/spec.md'\\nc = io.open(p, 'r', encoding='utf-8', newline='').read()\\neol = '\\\\r\\\\n' if '\\\\r\\\\n' in c else '\\\\n'\\nold = '**\u9418\u8235\udc80\udc81**\u951b\u6c33\udcae\u6350\udcae\u00a4\udcae\u3128\udcae\u70d8\u6b8f\u934b\u6ed0\u7d31\u5bb8\u8336\udcae\u677f\u7d8d\u7ead\udcae\u7481\u3089\u300d\u951b\u5c7d\u76bb\u93c8\udcaa\u6769\u6d98\u53c6 plan\u9286\u4e7casks \u93b4\udc96 implement'\\nassert c.count(old) == 1\\nnew = old + eol + eol + '**2026-09-03 \u95ae\u3125\u578e\u9480\u85c9\u6e74**\u951b\u6b30pple Pay\u951b\u5767ayType=5\u951b\u590c\u568e\u6d93\u5a45\u4ec8\u748b\u51ad\udca2\udcab OMG \u9352\u6d98\u7f13\u93cd\uffe0\u7359\u93b7\u6394\u7cb7\u951b\u581d\u5f67\u7481\udca4 payType=2\u951b\u5908\udc80\u509b\u5bdc\u93c8\udcac\u7459\u52ec\u7278\u5bb8\u832c\u2018\u7481\u3087\u6b91\u9286\u5c7c\u4fca\u9422\u3125\u5d31\u6d93\udc8e Apple Pay \u935a\u5c80\u7c8d\u935a\u5c7d\u6662\u9286\u5d88\udcaf\udcad\u6d94\u591b\u7d1d\u934f\u5823\u60e4\u9366\u7248\u7b2d\u95ac\u64b3\u7d8a\u7f01\u52eb\u57bd\u93c2\udcad `OrderLifecycleService.isCardWalletPayType`\u951b\udc882 \u93b4\udc96 5 \u9367\u56ea\u86cb OMG\u951b\u591b\u7d1d\u93c7\u630e\u5d32\u6d5c\udc86 OMG \u9352\u6d98\u7f13\u93cd\uffe0\u7359\u9286\u4f79\u5e34\u9357\u66df\u656e\u6d60\u6a40\u68ec\u59b2\u6d96\udc80\u4f7a"}
 {"timestamp":"2026-09-03T10:02:48Z","event":"parse_error","raw":"{\"session_id\":\"01b4b2c0-b6f1-405c-975f-7c995ab50aa5\",\"transcript_path\":\"C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\01b4b2c0-b6f1-405c-975f-7c995ab50aa5.jsonl\",\"cwd\":\"E:\\\\QtwCode\\\\foodie\\\\foodie_server\",\"prompt_id\":\"ea95a079-0adc-4846-bfed-7428843493b3\",\"permission_mode\":\"bypassPermissions\",\"effort\":{\"level\":\"max\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"cd \\\"E:/QtwCode/foodie/foodie_server\\\" && git add ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java specs/023-payment-provider-routing/spec.md .claude/homunculus/observations.jsonl && git commit -m \\\"OMG\u5a13\u72bb\u4ebe\u93b5\u630e\u5e34Apple Pay(payType=5)\u951b\u6c2b\u6168\u6fb6\u5d85\u57b1\u5be4\u70d8\u656e\u6d60\u6a3f\udca2\udcab\u93b7\u6283\" -m \\\"\u93b8\udc89023\u5bb8\u832c\u2018\u7481\u3087\u6b91\u6dc7\uff04\u6564\u9357\udca1+ApplePay\u935a\u5c80\u7c8d\u935a\u5c7d\u6662\u7487\udcad\u6d94\u591b\u7d1d\u93c2\u677f\udca2\u7012sCardWalletPayType(2\u93b4\udc965)\u951b\u6d99\u6d5b\u93b9\udca2OMG\u9352\u6d98\u7f13\u93cd\uffe0\u7359/\u93ba\u30e5\u5d1f\u93c0\udcaf\u6d60\u6a40\u68ec\u59b2\udc9b/\u9418\u8235\udc80\u4f79\u5e39\u6769\u6d98\u6e6a\u7efe\u630e\u656e\u6d60\u6a3f\udca6\u4f79\u7730/\u9a9e\u51b2\u5f74\u6d5c\u3086\u69d7\u9359\u950b\u7161\u7487\u3220\u6d13\u6fb6\u52eb\u5d1f\u934a\u517c\u762e\u6748\u51bf\u7d31\u741a\udcab\u93b7\u6395\udcae\u3220\u5d1f991788429392064\u95ae\u3127\u8bb2\u935a\u5ea8\u5678\u93c2\u677f\u57b1\u5be4\u54c4\u5d46\u93ad\u3220"}
+{"timestamp":"2026-09-03T10:02:57Z","event":"parse_error","raw":"{\"session_id\":\"01b4b2c0-b6f1-405c-975f-7c995ab50aa5\",\"transcript_path\":\"C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\01b4b2c0-b6f1-405c-975f-7c995ab50aa5.jsonl\",\"cwd\":\"E:\\\\QtwCode\\\\foodie\\\\foodie_server\",\"prompt_id\":\"ea95a079-0adc-4846-bfed-7428843493b3\",\"permission_mode\":\"bypassPermissions\",\"effort\":{\"level\":\"max\"},\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"cd \\\"E:/QtwCode/foodie/foodie_server\\\" && git add ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java specs/023-payment-provider-routing/spec.md .claude/homunculus/observations.jsonl && git commit -m \\\"OMG\u5a13\u72bb\u4ebe\u93b5\u630e\u5e34Apple Pay(payType=5)\u951b\u6c2b\u6168\u6fb6\u5d85\u57b1\u5be4\u70d8\u656e\u6d60\u6a3f\udca2\udcab\u93b7\u6283\" -m \\\"\u93b8\udc89023\u5bb8\u832c\u2018\u7481\u3087\u6b91\u6dc7\uff04\u6564\u9357\udca1+ApplePay\u935a\u5c80\u7c8d\u935a\u5c7d\u6662\u7487\udcad\u6d94\u591b\u7d1d\u93c2\u677f\udca2\u7012sCardWalletPayType(2\u93b4\udc965)\u951b\u6d99\u6d5b\u93b9\udca2OMG\u9352\u6d98\u7f13\u93cd\uffe0\u7359/\u93ba\u30e5\u5d1f\u93c0\udcaf\u6d60\u6a40\u68ec\u59b2\udc9b/\u9418\u8235\udc80\u4f79\u5e39\u6769\u6d98\u6e6a\u7efe\u630e\u656e\u6d60\u6a3f\udca6\u4f79\u7730/\u9a9e\u51b2\u5f74\u6d5c\u3086\u69d7\u9359\u950b\u7161\u7487\u3220\u6d13\u6fb6\u52eb\u5d1f\u934a\u517c\u762e\u6748\u51bf\u7d31\u741a\udcab\u93b7\u6395\udcae\u3220\u5d1f991788429392064\u95ae\u3127\u8bb2\u935a\u5ea8\u5678\u93c2\u677f\u57b1\u5be4\u54c4\u5d46\u93ad"}
+{"timestamp":"2026-09-03T10:03:11Z","event":"parse_error","raw":"{\"session_id\":\"01b4b2c0-b6f1-405c-975f-7c995ab50aa5\",\"transcript_path\":\"C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\01b4b2c0-b6f1-405c-975f-7c995ab50aa5.jsonl\",\"cwd\":\"E:\\\\QtwCode\\\\foodie\\\\foodie_server\",\"prompt_id\":\"ea95a079-0adc-4846-bfed-7428843493b3\",\"permission_mode\":\"bypassPermissions\",\"effort\":{\"level\":\"max\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"python << 'PYEOF'\\nimport io\\np = r'C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\memory\\\\project-016-omg-payment.md'\\nc = io.open(p, 'r', encoding='utf-8', newline='').read()\\nadd = '\\\\n\u9286\udc902026-09-03 Apple Pay\u8930\u6394\u7c8d\u9286\u6184ayType=5(Apple Pay)\u6d93\udc8e2(\u6dc7\uff04\u6564\u9357\udca1)\u935a\u5c80\u7c8d\u935a\u5c7d\u6662\u9367\u56ea\u86cbOMG\u951b\u5c7d\u57bd\u7039\u6c2d\u7b2d\u95ac\u64b3\u7e40\u6924\u8364\u6564 OrderLifecycleService.isCardWalletPayType(2\u93b4\udc965)\u951b\u5c80\udca6\u4f79\udcad\u3220\u5d1f\u59e3\udc94 payType==\\\"2\\\"\u951b\u581f\u6d58\u9477\udcb4 /pay/omg/create \u93b7\udc92 Apple Pay \u9357\u66ea\u7d1dcommit 07715c2 \u6dc7\udcae\u6fb6\u5d85\u6d13\u6fb6\u52eb\u5d1f\u934a\u517c\u762e\u6748\u51bf\u7d30\u9352\u6d98\u7f13\u93cd\uffe0\u7359/\u93ba\u30e5\u5d1f\u93c0\udcaf\u6d60\u6a40\u68ec\u59b2\udc9b/\u9418\u8235\udc80\u4f79\u5e39\u6769\u6d9c\udca6\u4f79\u7730/\u9a9e\u51b2\u5f74\u6d5c\u3086\u69d7\u9359\u950b\u7161\u7487\udca2\u951b\u5908\udc80\u4fd3\\n'\\nif 'isCardWalletPayType' not in c:\\n    c = c.rstrip('\\\\n') + add\\n    io.open(p, 'w', encoding='utf-8', ne"}
+{"timestamp":"2026-09-03T10:03:12Z","event":"parse_error","raw":"{\"session_id\":\"01b4b2c0-b6f1-405c-975f-7c995ab50aa5\",\"transcript_path\":\"C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\01b4b2c0-b6f1-405c-975f-7c995ab50aa5.jsonl\",\"cwd\":\"E:\\\\QtwCode\\\\foodie\\\\foodie_server\",\"prompt_id\":\"ea95a079-0adc-4846-bfed-7428843493b3\",\"permission_mode\":\"bypassPermissions\",\"effort\":{\"level\":\"max\"},\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"python << 'PYEOF'\\nimport io\\np = r'C:\\\\Users\\\\qmj\\\\.claude\\\\projects\\\\E--QtwCode-foodie-foodie-server\\\\memory\\\\project-016-omg-payment.md'\\nc = io.open(p, 'r', encoding='utf-8', newline='').read()\\nadd = '\\\\n\u9286\udc902026-09-03 Apple Pay\u8930\u6394\u7c8d\u9286\u6184ayType=5(Apple Pay)\u6d93\udc8e2(\u6dc7\uff04\u6564\u9357\udca1)\u935a\u5c80\u7c8d\u935a\u5c7d\u6662\u9367\u56ea\u86cbOMG\u951b\u5c7d\u57bd\u7039\u6c2d\u7b2d\u95ac\u64b3\u7e40\u6924\u8364\u6564 OrderLifecycleService.isCardWalletPayType(2\u93b4\udc965)\u951b\u5c80\udca6\u4f79\udcad\u3220\u5d1f\u59e3\udc94 payType==\\\"2\\\"\u951b\u581f\u6d58\u9477\udcb4 /pay/omg/create \u93b7\udc92 Apple Pay \u9357\u66ea\u7d1dcommit 07715c2 \u6dc7\udcae\u6fb6\u5d85\u6d13\u6fb6\u52eb\u5d1f\u934a\u517c\u762e\u6748\u51bf\u7d30\u9352\u6d98\u7f13\u93cd\uffe0\u7359/\u93ba\u30e5\u5d1f\u93c0\udcaf\u6d60\u6a40\u68ec\u59b2\udc9b/\u9418\u8235\udc80\u4f79\u5e39\u6769\u6d9c\udca6\u4f79\u7730/\u9a9e\u51b2\u5f74\u6d5c\u3086\u69d7\u9359\u950b\u7161\u7487\udca2\u951b\u5908\udc80\u4fd3\\n'\\nif 'isCardWalletPayType' not in c:\\n    c = c.rstrip('\\\\n') + add\\n    io.open(p, 'w', encoding='utf-8', n"}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 34 - 0
.tmp/lanhu-flash-delivery/analysis.json


BIN
.tmp/lanhu-flash-delivery/mcp-01.png


BIN
.tmp/lanhu-flash-delivery/mcp-02.png


BIN
.tmp/lanhu-flash-delivery/mcp-03.png


BIN
.tmp/lanhu-flash-delivery/mcp-04.png


BIN
.tmp/lanhu-flash-delivery/mcp-05.png


BIN
.tmp/lanhu-flash-delivery/mcp-06.png


BIN
.tmp/lanhu-flash-delivery/mcp-07.png


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

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

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

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

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

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

+ 1 - 0
.video_agent/plugin_root

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

+ 13 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.stall;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.ruoyi.app.utils.RsaMima;
+import com.ruoyi.app.user.BusinessPhoneService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -40,6 +41,9 @@ public class StallController extends BaseController {
     @Autowired
     private IUserWalletService userWalletService;
 
+    @Autowired
+    private BusinessPhoneService businessPhoneService;
+
     /**
      * 获取摊位列表
      * 夜市用户(type=3):返回自己创建的所有摊位
@@ -133,8 +137,12 @@ public class StallController extends BaseController {
         if (stallOwner.getUserName() == null || stallOwner.getUserName().isEmpty()) {
             return error("用户名不能为空");
         }
-        if (stallOwner.getPhone() == null || stallOwner.getPhone().isEmpty()) {
-            return error("手机号不能为空");
+        String telPhone = stallOwner.getTelPhone();
+        if (telPhone == null || telPhone.isBlank()) {
+            telPhone = stallOwner.getPhone();
+        }
+        if (telPhone == null || telPhone.isBlank()) {
+            return error(MessageUtils.message("no.user.phone.blank"));
         }
         if (stallOwner.getPassword() == null || stallOwner.getPassword().isEmpty()) {
             return error("密码不能为空");
@@ -149,16 +157,11 @@ public class StallController extends BaseController {
             return error("用户名已存在");
         }
 
-        // 检查手机号是否已注册
-        InfoUser existUser = infoUserService.lambdaQuery()
-                .eq(InfoUser::getPhone, stallOwner.getPhone())
-                .eq(InfoUser::getDelFlag, "0")
-                .one();
-        if (existUser != null) {
-            return error("该手机号已注册");
-        }
+        businessPhoneService.ensureUnique(telPhone, null);
 
         // 设置摊位主属性
+        stallOwner.setTelPhone(telPhone);
+        stallOwner.setPhone(null);
         stallOwner.setUserType("4");
         stallOwner.setStatus("0");
         stallOwner.setDelFlag("0");

+ 40 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/BusinessPhoneService.java

@@ -0,0 +1,40 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class BusinessPhoneService {
+
+    private static final List<String> BUSINESS_USER_TYPES = List.of("1", "2", "3", "4", "5");
+
+    private final IInfoUserService infoUserService;
+
+    public BusinessPhoneService(IInfoUserService infoUserService) {
+        this.infoUserService = infoUserService;
+    }
+
+    public boolean isBusinessUserType(String userType) {
+        return BUSINESS_USER_TYPES.contains(userType);
+    }
+
+    public void ensureUnique(String telPhone, Long excludeUserId) {
+        if (telPhone == null || telPhone.isBlank()) {
+            return;
+        }
+        LambdaQueryWrapper<InfoUser> query = new LambdaQueryWrapper<InfoUser>()
+                .eq(InfoUser::getTelPhone, telPhone)
+                .eq(InfoUser::getDelFlag, "0")
+                .in(InfoUser::getUserType, BUSINESS_USER_TYPES)
+                .ne(excludeUserId != null, InfoUser::getUserId, excludeUserId);
+        if (infoUserService.count(query) > 0) {
+            throw new ServiceException(MessageUtils.message("no.user.mobile.exist"));
+        }
+    }
+}

+ 127 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -46,6 +46,7 @@ import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.MobileSMS;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.utils.oauth.LineOAuthProperties;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
@@ -108,6 +109,10 @@ public class InfoUserController extends BaseController {
     @Autowired
     private OAuthVerifyService oauthVerifyService;
     @Autowired
+    private LineOAuthProperties lineOAuthProperties;
+    @Autowired
+    private BusinessPhoneService businessPhoneService;
+    @Autowired
     private MerchantStoreAccessService merchantStoreAccessService;
     @Autowired
     private MerchantTokenSessionService merchantTokenSessionService;
@@ -316,6 +321,7 @@ public class InfoUserController extends BaseController {
      * @return
      */
     public AjaxResult createQsUser(UserDTO userDTO) {
+        businessPhoneService.ensureUnique(userDTO.getTelPhone(), null);
         InfoUser user = new InfoUser();
         UUIDUtil uuid = new UUIDUtil();
         user.setUserName(userDTO.getUserName());
@@ -371,6 +377,7 @@ public class InfoUserController extends BaseController {
      * @return
      */
     public AjaxResult createShUser(UserDTO userDTO) {
+        businessPhoneService.ensureUnique(userDTO.getTelPhone(), null);
         InfoUser user = new InfoUser();
         UUIDUtil uuid = new UUIDUtil();
         user.setUserName(userDTO.getUserName());
@@ -446,6 +453,7 @@ public class InfoUserController extends BaseController {
                 LoginUserDto userDto = new LoginUserDto();
                 userDto.setUserId(user.getUserId());
                 userDto.setUserName(user.getUserName());
+                userDto.setProvider("phone");
                 fillLoginUserInfo(userDto);
                 String token = JwtUtil.setToken(tokenKey, userDto);
                 return success(MessageUtils.message("no.user.login.success"), merchantLoginView(user), token);
@@ -504,6 +512,7 @@ public class InfoUserController extends BaseController {
                 LoginUserDto userDto = new LoginUserDto();
                 userDto.setUserId(user.getUserId());
                 userDto.setUserName(user.getUserName());
+                userDto.setProvider("phone");
                 fillLoginUserInfo(userDto);
                 String token = JwtUtil.setToken(CacheConstants.QS_TOKEN_KEY, userDto);
                 return success(MessageUtils.message("no.user.login.success"), user, token);
@@ -667,6 +676,9 @@ public class InfoUserController extends BaseController {
             if (user == null) {
                 return error(MessageUtils.message("no.user.not.exist"));
             } else {
+                if (businessPhoneService.isBusinessUserType(user.getUserType())) {
+                    businessPhoneService.ensureUnique(userDTO.getTelPhone(), user.getUserId());
+                }
                 InfoUser infoUser = new InfoUser();
                 infoUser.setUserId(user.getUserId());
                 infoUser.setTelPhone(userDTO.getTelPhone());
@@ -735,10 +747,13 @@ public class InfoUserController extends BaseController {
     public AjaxResult setuser(@RequestHeader String token, @RequestBody InfoUser infoUser) {
         JwtUtil jwtUtil = new JwtUtil();
         String id = jwtUtil.getusid(token);
+        InfoUser current = infoUserService.getById(id);
+        if (current != null && businessPhoneService.isBusinessUserType(current.getUserType())) {
+            businessPhoneService.ensureUnique(infoUser.getTelPhone(), current.getUserId());
+        }
         normalizeDeliveryType(infoUser);
         InfoUser user = new InfoUser();
         user.setUserId(Long.valueOf(id));
-        user.setUserType(infoUser.getUserType());
         user.setCid(infoUser.getCid());
         user.setCidType(infoUser.getCidType());
         user.setDeviceToken(infoUser.getDeviceToken());
@@ -747,7 +762,6 @@ public class InfoUserController extends BaseController {
         user.setAvatar(infoUser.getAvatar());
         user.setEmail(infoUser.getEmail());
         user.setSex(infoUser.getSex());
-        user.setStatus(infoUser.getStatus());
         user.setThiscode(infoUser.getThiscode());
         user.setAnnex(infoUser.getAnnex());
         user.setTelPhone(infoUser.getTelPhone());
@@ -898,6 +912,8 @@ public class InfoUserController extends BaseController {
             LoginUserDto userDto = new LoginUserDto();
             userDto.setUserId(user.getUserId());
             userDto.setUserName(user.getUserName());
+            Object provider = JwtUtil.verifyToken(token).get("provider");
+            userDto.setProvider(provider == null || "null".equals(provider) ? "phone" : String.valueOf(provider));
             fillLoginUserInfo(userDto);
             String wtoken = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, userDto);
             return success(MessageUtils.message("no.user.token.success"), wtoken);
@@ -966,6 +982,7 @@ public class InfoUserController extends BaseController {
         LoginUserDto userDto = new LoginUserDto();
         userDto.setUserId(inus.getUserId());
         userDto.setUserName(inus.getPhone());
+        userDto.setProvider("phone");
         fillLoginUserInfo(userDto);
         String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, userDto);
         return success(MessageUtils.message("no.user.login.success"), inus, token);
@@ -1010,6 +1027,7 @@ public class InfoUserController extends BaseController {
         LoginUserDto userDto = new LoginUserDto();
         userDto.setUserId(user.getUserId());
         userDto.setUserName(user.getPhone());
+        userDto.setProvider("phone");
         fillLoginUserInfo(userDto);
         String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, userDto);
         return success(MessageUtils.message("no.user.login.success"), user, token);
@@ -1026,6 +1044,7 @@ public class InfoUserController extends BaseController {
         if (dto == null || dto.getProvider() == null || dto.getProvider().isEmpty()) {
             return error(MessageUtils.message("no.oauth.provider.blank"));
         }
+        LineOAuthProperties.ResolvedChannel lineChannel = resolveLineChannel(dto.getProvider());
         log.info("[OAuth] oauthLogin provider={}", dto.getProvider());
         String providerUid = oauthVerifyService.verify(dto.getProvider(), dto.getCredential());
 
@@ -1036,7 +1055,7 @@ public class InfoUserController extends BaseController {
                         .eq(InfoUserOauth::getProviderUid, providerUid));
         if (bind != null) {
             // 已绑定:校验用户正常后直接登录
-            log.info("[OAuth] 已绑定 provider={}, providerUid={}, userId={}", dto.getProvider(), providerUid, bind.getUserId());
+            log.info("[OAuth] 已绑定 provider={}, userId={}", dto.getProvider(), bind.getUserId());
             QueryWrapper<InfoUser> q = new QueryWrapper<>();
             q.eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0");
             InfoUser u = infoUserService.getOne(q);
@@ -1044,6 +1063,9 @@ public class InfoUserController extends BaseController {
                 log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
                 return error(MessageUtils.message("no.user.stop"));
             }
+            if (lineChannel != null && !canLineLogin(lineChannel, u)) {
+                return error(MessageUtils.message("no.user.stop"));
+            }
             u.setCid(dto.getCid());
             u.setCidType(dto.getCidType());
             u.setDeviceToken(dto.getDeviceToken());
@@ -1053,7 +1075,7 @@ public class InfoUserController extends BaseController {
         }
 
         // 未绑定:缓存 {provider, providerUid},返回 needPhone + tempKey
-        log.info("[OAuth] 未绑定,返回 needPhone provider={}, providerUid={}", dto.getProvider(), providerUid);
+        log.info("[OAuth] 未绑定,返回 needPhone provider={}", dto.getProvider());
         String tempKey = UUID.randomUUID().toString().replace("-", "");
         redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
                 dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
@@ -1067,21 +1089,27 @@ public class InfoUserController extends BaseController {
      * 三方登录第二步:手机号 + 短信验证 → 已注册关联 / 未注册新建 → 写绑定 → 签 token。
      */
     @Anonymous
+    @Transactional(rollbackFor = Exception.class)
     @PostMapping("/oauthBindPhone")
     public AjaxResult oauthBindPhone(@RequestBody OAuthBindDto dto) {
         if (dto == null || dto.getTempKey() == null || dto.getTempKey().isEmpty()) {
             log.warn("[OAuth] oauthBindPhone 缺 tempKey");
             return error(MessageUtils.message("no.oauth.tempkey.missing"));
         }
-        String cached = redisCache.getCacheObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
-        if (cached == null) {
-            log.warn("[OAuth] tempKey 已过期/不存在 tempKey={}", dto.getTempKey());
+        String tempCacheKey = OAUTH_TEMP_PREFIX + dto.getTempKey();
+        String cached = redisCache.getCacheObject(tempCacheKey);
+        if (cached == null || !redisCache.deleteObject(tempCacheKey)) {
+            log.warn("[OAuth] tempKey 已过期/不存在");
             return error(MessageUtils.message("no.oauth.tempkey.expired"));
         }
         int at = cached.indexOf('@');
+        if (at <= 0 || at == cached.length() - 1) {
+            return error(MessageUtils.message("no.oauth.tempkey.expired"));
+        }
         String provider = cached.substring(0, at);
         String providerUid = cached.substring(at + 1);
-        log.info("[OAuth] 绑定流程 provider={}, providerUid={}", provider, providerUid);
+        LineOAuthProperties.ResolvedChannel lineChannel = resolveLineChannel(provider);
+        log.info("[OAuth] 绑定流程 provider={}", provider);
 
         // 容错:若期间已被绑定,直接登录
         InfoUserOauth exist = infoUserOauthMapper.selectOne(
@@ -1091,11 +1119,17 @@ public class InfoUserController extends BaseController {
         if (exist != null) {
             log.info("[OAuth] 绑定期间已被绑定,直接登录 userId={}", exist.getUserId());
             InfoUser u = infoUserService.getById(exist.getUserId());
+            if (lineChannel != null && !canLineLogin(lineChannel, u)) {
+                return error(MessageUtils.message("no.user.stop"));
+            }
             return issueOauthToken(u, provider);
         }
 
         // 验短信(复用 lodeing 逻辑:redis code 或万能码 8888)
         String phone = dto.getPhone();
+        if (phone == null || phone.isBlank()) {
+            return error(MessageUtils.message("no.oauth.phone.blank"));
+        }
         String xcode = redisCache.getCacheObject(phone.trim().replaceAll("\\+", ""));
         boolean codeOk = (xcode != null && xcode.equals(dto.getCode())) || "8888".equals(dto.getCode());
         if (!codeOk) {
@@ -1103,14 +1137,26 @@ public class InfoUserController extends BaseController {
             return error(MessageUtils.message("no.user.jcaptcha.error"));
         }
 
-        // 查/建用户(手机号始终为主键)
-        InfoUser user = infoUserService.getuser(phone);
+        InfoUser user;
+        if (lineChannel != null) {
+            user = findLineBindingUser(lineChannel, phone);
+        } else {
+            user = infoUserService.getuser(phone);
+        }
+        if (lineChannel != null && !lineChannel.allowCreate()) {
+            if (user == null) {
+                return error(MessageUtils.message("no.user.not.exist"));
+            }
+            if (!canLineLogin(lineChannel, user)) {
+                return error(MessageUtils.message("no.user.stop"));
+            }
+        }
         if (user != null && !"0".equals(user.getStatus())) {
             // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
             log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
             return error(MessageUtils.message("no.user.stop"));
         }
-        if (user == null) {
+        if (user == null && (lineChannel == null || lineChannel.allowCreate())) {
             // 新建:昵称=手机号(与 createUser 一致)
             InfoUser info = new InfoUser();
             info.setPhone(phone);
@@ -1121,8 +1167,14 @@ public class InfoUserController extends BaseController {
             info.setVoIPToken(dto.getVoIPToken());
             info.setUserType("0");
             info.setMycode(new UUIDUtil().get8UUID());
-            infoUserService.saveOrUpdate(info);
-            user = infoUserService.getuser(phone);
+            if (!infoUserService.saveOrUpdate(info)) {
+                throw new ServiceException(MessageUtils.message("no.system.error"));
+            }
+            user = lineChannel == null
+                    ? infoUserService.getuser(phone) : findLineBindingUser(lineChannel, phone);
+            if (user == null) {
+                return error(MessageUtils.message("no.user.not.exist"));
+            }
             createUserWallet(user.getUserId());
             log.info("[OAuth] 新建账号 userId={}", user.getUserId());
         } else {
@@ -1130,7 +1182,9 @@ public class InfoUserController extends BaseController {
             user.setCidType(dto.getCidType());
             user.setDeviceToken(dto.getDeviceToken());
             user.setVoIPToken(dto.getVoIPToken());
-            infoUserService.saveOrUpdate(user);
+            if (!infoUserService.saveOrUpdate(user)) {
+                throw new ServiceException(MessageUtils.message("no.system.error"));
+            }
             log.info("[OAuth] 关联已有账号 userId={}", user.getUserId());
         }
 
@@ -1140,10 +1194,11 @@ public class InfoUserController extends BaseController {
         bind.setProvider(provider);
         bind.setProviderUid(providerUid);
         bind.setCreateTime(new Date());
-        infoUserOauthMapper.insert(bind);
-        log.info("[OAuth] 写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
+        if (infoUserOauthMapper.insert(bind) != 1) {
+            throw new ServiceException(MessageUtils.message("no.system.error"));
+        }
+        log.info("[OAuth] 写入绑定 provider={}, userId={}", provider, user.getUserId());
 
-        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
         return issueOauthToken(user, provider);
     }
 
@@ -1160,14 +1215,58 @@ public class InfoUserController extends BaseController {
         if (user == null) {
             return error(MessageUtils.message("no.user.not.exist"));
         }
-        redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + user.getUserId() + ":" + "*");
+        if (!"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag())) {
+            return error(MessageUtils.message("no.user.stop"));
+        }
+        LineOAuthProperties.ResolvedChannel lineChannel = resolveLineChannel(provider);
+        if (lineChannel != null && !canLineLogin(lineChannel, user)) {
+            return error(MessageUtils.message("no.user.stop"));
+        }
+        String tokenKey = lineChannel == null ? CacheConstants.USER_TOKEN_KEY : lineChannel.tokenKey();
+        redisCache.deleteKeys(tokenKey + user.getUserId() + ":*");
         LoginUserDto dto = new LoginUserDto();
         dto.setUserId(user.getUserId());
-        dto.setUserName(user.getPhone());
+        dto.setUserName(lineChannel == null || lineChannel.allowCreate()
+                ? user.getPhone() : user.getUserName());
         dto.setProvider(provider);
         fillLoginUserInfo(dto);
-        String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
-        return success(MessageUtils.message("no.user.login.success"), user, token);
+        String token = JwtUtil.setToken(tokenKey, dto);
+        Object loginUser = lineChannel != null
+                && "line_merchant".equals(lineChannel.provider()) ? merchantLoginView(user) : user;
+        return success(MessageUtils.message("no.user.login.success"), loginUser, token);
+    }
+
+    private LineOAuthProperties.ResolvedChannel resolveLineChannel(String provider) {
+        return provider != null && provider.startsWith("line_")
+                ? lineOAuthProperties.requireChannel(provider) : null;
+    }
+
+    private boolean canLineLogin(LineOAuthProperties.ResolvedChannel channel, InfoUser user) {
+        if (user == null || !"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag())
+                || !channel.userTypes().contains(user.getUserType())) {
+            return false;
+        }
+        if (!MerchantAccountConstants.SUBACCOUNT_USER_TYPE.equals(user.getUserType())) {
+            return true;
+        }
+        if (!MerchantAccountConstants.STATUS_ENABLED.equals(user.getSubaccountStatus())) {
+            return false;
+        }
+        try {
+            merchantStoreAccessService.resolve(user.getUserId());
+            return true;
+        } catch (ServiceException exception) {
+            return false;
+        }
+    }
+
+    private InfoUser findLineBindingUser(LineOAuthProperties.ResolvedChannel channel, String phone) {
+        LambdaQueryWrapper<InfoUser> query = new LambdaQueryWrapper<InfoUser>()
+                .eq(channel.allowCreate(), InfoUser::getPhone, phone)
+                .eq(!channel.allowCreate(), InfoUser::getTelPhone, phone)
+                .eq(InfoUser::getDelFlag, "0")
+                .in(InfoUser::getUserType, channel.userTypes());
+        return infoUserService.getOne(query);
     }
 
     /**
@@ -1246,6 +1345,8 @@ public class InfoUserController extends BaseController {
         normalizeDeliveryType(infoUser);
         if (infoUserService.getinfouserName(infoUser.getUserName()) != null) {
             return error(MessageUtils.message("no.user.add") + infoUser.getUserName() + MessageUtils.message("no.user.login.exist"));
+        } else if (businessPhoneService.isBusinessUserType(infoUser.getUserType())) {
+            businessPhoneService.ensureUnique(infoUser.getTelPhone(), null);
         } else if (infoUserService.getinfoPhone(infoUser.getPhone()) != null) {
             return error(MessageUtils.message("no.user.add") + infoUser.getPhone() + MessageUtils.message("no.user.mobile.exist"));
         }
@@ -1276,6 +1377,11 @@ public class InfoUserController extends BaseController {
             return error(MessageUtils.message("no.user.audit.reject.reason.required"));
         }
         normalizeDeliveryType(infoUser);
+        String effectiveUserType = infoUser.getUserType() != null
+                ? infoUser.getUserType() : existing == null ? null : existing.getUserType();
+        if (businessPhoneService.isBusinessUserType(effectiveUserType)) {
+            businessPhoneService.ensureUnique(infoUser.getTelPhone(), infoUser.getUserId());
+        }
         return toAjax(infoUserService.updateInfoUser(infoUser));
     }
 

+ 64 - 76
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -1,27 +1,28 @@
 package com.ruoyi.app.user;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.utils.oauth.LineOAuthProperties;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.common.annotation.Anonymous;
-import com.ruoyi.common.constant.CacheConstants;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.model.LoginUserDto;
 import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.ServletUtils;
 import com.ruoyi.common.utils.ip.AddressUtils;
 import com.ruoyi.common.utils.ip.IpUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.domain.constants.MerchantAccountConstants;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.utils.JwtUtil;
 import eu.bitwalker.useragentutils.UserAgent;
 import jakarta.servlet.http.HttpServletResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -33,117 +34,104 @@ import java.nio.charset.StandardCharsets;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 
-/**
- * LINE 登录服务端回调(017-oauth-login 增量,2026-08-05)。
- *
- * <p><b>为什么需要它</b>:当用户走「唤起 LINE App / 系统浏览器」授权时,LINE 把重定向发给
- * 注册的 redirect_uri(后端 https URL),uniapp(另一个 App)无法在中间截获 code。这时必须由
- * 后端接住回调、换 token、完成登录,再把结果 302 跳回 App(自定义 scheme)。原 {@code /oauthLogin}
- * 保留不动,覆盖「前端自己拿到 code 再 POST 给后端」的另一种场景(H5 / webview / SDK)。
- *
- * <p><b>流程</b>:LINE → {@code GET /auth/line/callback?code=...&state=...}
- * → {@link OAuthVerifyService#verify verify("line", code)}(code 换 token 取 userId)
- * → 查 info_user_oauth:已绑定→签 JWT token;未绑定→生成 tempKey(needPhone)
- * → 302 跳到 {@code oauth.line.app-redirect}(如 com.twanmsdyh.app://oauthLogin)带结果:
- * <ul>
- *   <li>已绑定:{@code <app-redirect>?token=xxx}</li>
- *   <li>未绑定:{@code <app-redirect>?needPhone=1&tempKey=xxx}(App 弹手机号+短信码 UI 后调 /infouser/user/oauthBindPhone)</li>
- *   <li>异常:{@code <app-redirect>?error=xxx}</li>
- * </ul>
- *
- * <p><b>redirect_uri 三方一致性</b>:LINE 要求「前端 authorize 的 redirect_uri」「后端换 token 的
- * redirect_uri」「LINE Console 回调白名单」三者完全一致,否则回 400 redirect_uri_mismatch。
- * 故 application.yml 的 {@code oauth.line.redirect-uri} 必须与 Console 一致(= 本端点地址)。
- *
- * @author foodie
- * @date 2026-08-05
- */
+/** LINE 三端共用服务端回调。 */
 @RestController
 @RequestMapping("/auth/line")
 public class LineCallbackController extends BaseController {
 
     private static final Logger log = LoggerFactory.getLogger(LineCallbackController.class);
-
-    /** 与 InfoUserController.OAUTH_TEMP_PREFIX 一致:未绑定临时凭证 Redis 前缀(oauthBindPhone 消费) */
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
 
     @Autowired
     private OAuthVerifyService oauthVerifyService;
     @Autowired
+    private LineOAuthProperties lineOAuthProperties;
+    @Autowired
     private IInfoUserService infoUserService;
     @Autowired
     private InfoUserOauthMapper infoUserOauthMapper;
     @Autowired
     private RedisCache redisCache;
+    @Autowired
+    private MerchantStoreAccessService merchantStoreAccessService;
 
-    /** 后端登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/tempKey/error) */
-    @Value("${oauth.line.app-redirect}")
-    private String appRedirect;
-
-    /**
-     * LINE 服务端回调:接 code → 换 token → 登录 → 302 回 App。
-     * 用 @Anonymous 放行(LINE 以浏览器/App 身份回调,无 token)。
-     */
     @Anonymous
     @GetMapping("/callback")
-    public void callback(@RequestParam(value = "code", required = false) String code,
+    public void callback(@RequestParam(value = "provider", required = false) String provider,
+                         @RequestParam(value = "code", required = false) String code,
                          @RequestParam(value = "state", required = false) String state,
                          HttpServletResponse response) throws IOException {
+        LineOAuthProperties.ResolvedChannel channel;
+        try {
+            channel = lineOAuthProperties.requireChannel(provider);
+        } catch (ServiceException exception) {
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+            return;
+        }
+
         try {
             if (code == null || code.isEmpty()) {
-                log.warn("[OAuth][LINE] callback 缺 code 参数, state={}", state);
-                redirectToApp(response, "error", "no_code");
+                log.warn("[OAuth][LINE] callback 缺少 code, provider={}, state={}", provider, state);
+                redirectToApp(response, channel.appRedirect(), "error", "no_code");
                 return;
             }
-            log.info("[OAuth][LINE] callback 收到 code(len={}), state={}", code.length(), state);
-
-            // code → userId(OAuthVerifyService.verifyLine:code 换 access_token 再取 profile)
-            String providerUid = oauthVerifyService.verify("line", code);
-
+            String providerUid = oauthVerifyService.verify(provider, code);
             InfoUserOauth bind = infoUserOauthMapper.selectOne(
                     new LambdaQueryWrapper<InfoUserOauth>()
-                            .eq(InfoUserOauth::getProvider, "line")
+                            .eq(InfoUserOauth::getProvider, provider)
                             .eq(InfoUserOauth::getProviderUid, providerUid));
             if (bind != null) {
-                InfoUser u = infoUserService.getOne(new QueryWrapper<InfoUser>()
-                        .eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0"));
-                if (u == null) {
-                    log.warn("[OAuth][LINE] callback 已绑定但账号已停用 userId={}", bind.getUserId());
-                    redirectToApp(response, "error", "user_stopped");
+                InfoUser user = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
+                        .eq(InfoUser::getUserId, bind.getUserId())
+                        .eq(InfoUser::getStatus, "0")
+                        .eq(InfoUser::getDelFlag, "0"));
+                if (!canLogin(channel, user)) {
+                    redirectToApp(response, channel.appRedirect(), "error", "user_stopped");
                     return;
                 }
-                String token = buildOauthToken(u, "line");
-                log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", u.getUserId());
-                response.sendRedirect(appRedirect + "?token=" + enc(token));
+                String token = buildOauthToken(user, channel);
+                response.sendRedirect(channel.appRedirect() + "?token=" + enc(token));
                 return;
             }
 
-            // 未绑定:缓存 {line, providerUid},返回 needPhone + tempKey(与 oauthLogin 同款,oauthBindPhone 消费)
             String tempKey = UUID.randomUUID().toString().replace("-", "");
-            redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, "line@" + providerUid, 5, TimeUnit.MINUTES);
-            log.info("[OAuth][LINE] callback 未绑定,返回 needPhone providerUid={}", providerUid);
-            response.sendRedirect(appRedirect + "?needPhone=1&tempKey=" + enc(tempKey));
-        } catch (Exception e) {
-            log.error("[OAuth][LINE] callback 异常", e);
-            redirectToApp(response, "error", e.getMessage() == null ? "fail" : e.getMessage());
+            redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
+                    provider + "@" + providerUid, 5, TimeUnit.MINUTES);
+            response.sendRedirect(channel.appRedirect() + "?needPhone=1&tempKey=" + enc(tempKey));
+        } catch (Exception exception) {
+            log.error("[OAuth][LINE] callback 异常 provider={}", provider, exception);
+            redirectToApp(response, channel.appRedirect(), "error", "fail");
+        }
+    }
+
+    private boolean canLogin(LineOAuthProperties.ResolvedChannel channel, InfoUser user) {
+        if (user == null || !channel.userTypes().contains(user.getUserType())) {
+            return false;
+        }
+        if (!MerchantAccountConstants.SUBACCOUNT_USER_TYPE.equals(user.getUserType())) {
+            return true;
+        }
+        if (!MerchantAccountConstants.STATUS_ENABLED.equals(user.getSubaccountStatus())) {
+            return false;
+        }
+        try {
+            merchantStoreAccessService.resolve(user.getUserId());
+            return true;
+        } catch (ServiceException exception) {
+            return false;
         }
     }
 
-    /**
-     * 签发三方登录 token(与 InfoUserController.issueOauthToken 同款,仅返回 token 串不包 AjaxResult)。
-     * 此处刻意不复用 InfoUserController 的私有方法,避免改动已上线代码;两处逻辑保持一致。
-     */
-    private String buildOauthToken(InfoUser user, String provider) {
-        redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + user.getUserId() + ":" + "*");
+    private String buildOauthToken(InfoUser user, LineOAuthProperties.ResolvedChannel channel) {
+        redisCache.deleteKeys(channel.tokenKey() + user.getUserId() + ":*");
         LoginUserDto dto = new LoginUserDto();
         dto.setUserId(user.getUserId());
-        dto.setUserName(user.getPhone());
-        dto.setProvider(provider);
+        dto.setUserName(channel.allowCreate() ? user.getPhone() : user.getUserName());
+        dto.setProvider(channel.provider());
         fillLoginUserInfo(dto);
-        return JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+        return JwtUtil.setToken(channel.tokenKey(), dto);
     }
 
-    /** 填充登录设备/网络信息(IP、地点、浏览器、OS、登录时间),与 InfoUserController.fillLoginUserInfo 一致。 */
     private void fillLoginUserInfo(LoginUserDto userDto) {
         String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
         userDto.setIpaddr(ip);
@@ -154,12 +142,12 @@ public class LineCallbackController extends BaseController {
         userDto.setLoginTime(System.currentTimeMillis());
     }
 
-    /** 302 跳回 App:app-redirect + ?<key>=<value>(value 做 URL 编码)。 */
-    private void redirectToApp(HttpServletResponse response, String key, String value) throws IOException {
+    private void redirectToApp(HttpServletResponse response, String appRedirect,
+                               String key, String value) throws IOException {
         response.sendRedirect(appRedirect + "?" + key + "=" + enc(value));
     }
 
-    private static String enc(String s) {
-        return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
+    private static String enc(String value) {
+        return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
     }
 }

+ 8 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountApplicationService.java

@@ -32,17 +32,20 @@ public class MerchantSubaccountApplicationService {
     private final MerchantStoreAccessService accessService;
     private final PosStoreMapper posStoreMapper;
     private final MerchantTokenSessionService tokenSessionService;
+    private final BusinessPhoneService businessPhoneService;
 
     public MerchantSubaccountApplicationService(IInfoUserService infoUserService,
                                                 IMerchantSubaccountStoreService relationService,
                                                 MerchantStoreAccessService accessService,
                                                 PosStoreMapper posStoreMapper,
-                                                MerchantTokenSessionService tokenSessionService) {
+                                                MerchantTokenSessionService tokenSessionService,
+                                                BusinessPhoneService businessPhoneService) {
         this.infoUserService = infoUserService;
         this.relationService = relationService;
         this.accessService = accessService;
         this.posStoreMapper = posStoreMapper;
         this.tokenSessionService = tokenSessionService;
+        this.businessPhoneService = businessPhoneService;
     }
 
     public List<MerchantSubaccountView> listForOwner(Long ownerUserId) {
@@ -69,13 +72,14 @@ public class MerchantSubaccountApplicationService {
         String name = normalizeRequired(request.getName(), "merchant.subaccount.name.required");
         String password = normalizeRequired(request.getPassword(), "merchant.subaccount.password.required");
         List<Long> storeIds = validateStores(ownerUserId, request.getStoreIds());
-        if (infoUserService.getinfouserName(phone) != null || infoUserService.getinfoPhone(phone) != null) {
+        if (infoUserService.getinfouserName(phone) != null) {
             throw error("merchant.subaccount.phone.exists");
         }
+        businessPhoneService.ensureUnique(phone, null);
 
         InfoUser user = new InfoUser();
         user.setUserName(phone);
-        user.setPhone(phone);
+        user.setTelPhone(phone);
         user.setNickName(name);
         user.setPassword(password);
         user.setUserType(MerchantAccountConstants.SUBACCOUNT_USER_TYPE);
@@ -174,7 +178,7 @@ public class MerchantSubaccountApplicationService {
         MerchantSubaccountView view = new MerchantSubaccountView();
         view.setUserId(user.getUserId());
         view.setName(firstNonBlank(user.getNickName(), user.getFullName(), user.getUserName()));
-        view.setPhone(user.getPhone());
+        view.setPhone(user.getTelPhone());
         view.setMerchantOwnerId(ownerUserId);
         view.setOwnerEnabled(MerchantAccountConstants.STATUS_ENABLED.equals(user.getSubaccountStatus()));
         view.setPlatformEnabled(MerchantAccountConstants.STATUS_ENABLED.equals(user.getStatus()));

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java

@@ -11,7 +11,7 @@ import lombok.Data;
 @Data
 public class OAuthLoginDto {
 
-    /** 三方渠道 apple/google/line */
+    /** 三方渠道 apple/google/line_user/line_rider/line_merchant */
     private String provider;
 
     /** 凭证:Apple identityToken / Google idToken / LINE authorization code */

+ 132 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/LineOAuthProperties.java

@@ -0,0 +1,132 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+
+@Component
+@ConfigurationProperties(prefix = "oauth.line")
+public class LineOAuthProperties {
+
+    private String tokenUrl;
+    private String profileUrl;
+    private Channel user;
+    private Channel rider;
+    private Channel merchant;
+
+    public ResolvedChannel requireChannel(String provider) {
+        if ("line_user".equals(provider)) {
+            return resolved(provider, user, CacheConstants.USER_TOKEN_KEY, Set.of("0"), true);
+        }
+        if ("line_rider".equals(provider)) {
+            return resolved(provider, rider, CacheConstants.QS_TOKEN_KEY, Set.of("2"), false);
+        }
+        if ("line_merchant".equals(provider)) {
+            return resolved(provider, merchant, CacheConstants.SH_APP_TOKEN_KEY,
+                    Set.of("1", "3", "4", "5"), false);
+        }
+        throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
+    }
+
+    private ResolvedChannel resolved(String provider, Channel channel, String tokenKey,
+                                     Set<String> userTypes, boolean allowCreate) {
+        if (channel == null || isBlank(channel.getClientId()) || isBlank(channel.getClientSecret())
+                || isBlank(channel.getRedirectUri()) || isBlank(channel.getAppRedirect())) {
+            throw new ServiceException(MessageUtils.message("no.oauth.line.config.invalid"));
+        }
+        return new ResolvedChannel(provider, channel.getClientId(), channel.getClientSecret(),
+                channel.getRedirectUri(), channel.getAppRedirect(), tokenKey, userTypes, allowCreate);
+    }
+
+    private boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+
+    public String getTokenUrl() {
+        return tokenUrl;
+    }
+
+    public void setTokenUrl(String tokenUrl) {
+        this.tokenUrl = tokenUrl;
+    }
+
+    public String getProfileUrl() {
+        return profileUrl;
+    }
+
+    public void setProfileUrl(String profileUrl) {
+        this.profileUrl = profileUrl;
+    }
+
+    public Channel getUser() {
+        return user;
+    }
+
+    public void setUser(Channel user) {
+        this.user = user;
+    }
+
+    public Channel getRider() {
+        return rider;
+    }
+
+    public void setRider(Channel rider) {
+        this.rider = rider;
+    }
+
+    public Channel getMerchant() {
+        return merchant;
+    }
+
+    public void setMerchant(Channel merchant) {
+        this.merchant = merchant;
+    }
+
+    public static class Channel {
+        private String clientId;
+        private String clientSecret;
+        private String redirectUri;
+        private String appRedirect;
+
+        public String getClientId() {
+            return clientId;
+        }
+
+        public void setClientId(String clientId) {
+            this.clientId = clientId;
+        }
+
+        public String getClientSecret() {
+            return clientSecret;
+        }
+
+        public void setClientSecret(String clientSecret) {
+            this.clientSecret = clientSecret;
+        }
+
+        public String getRedirectUri() {
+            return redirectUri;
+        }
+
+        public void setRedirectUri(String redirectUri) {
+            this.redirectUri = redirectUri;
+        }
+
+        public String getAppRedirect() {
+            return appRedirect;
+        }
+
+        public void setAppRedirect(String appRedirect) {
+            this.appRedirect = appRedirect;
+        }
+    }
+
+    public record ResolvedChannel(String provider, String clientId, String clientSecret,
+                                  String redirectUri, String appRedirect, String tokenKey,
+                                  Set<String> userTypes, boolean allowCreate) {
+    }
+}

+ 32 - 49
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java

@@ -24,6 +24,7 @@ import org.apache.http.impl.client.HttpClients;
 import org.apache.http.util.EntityUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
@@ -59,21 +60,13 @@ public class OAuthVerifyService {
     private String googleClientId;
     @Value("${oauth.google.tokeninfo-url}")
     private String googleTokeninfoUrl;
-    @Value("${oauth.line.profile-url}")
-    private String lineProfileUrl;
-    @Value("${oauth.line.token-url}")
-    private String lineTokenUrl;
-    @Value("${oauth.line.client-id}")
-    private String lineClientId;
-    @Value("${oauth.line.client-secret}")
-    private String lineClientSecret;
-    @Value("${oauth.line.redirect-uri}")
-    private String lineRedirectUri;
+    @Autowired
+    private LineOAuthProperties lineOAuthProperties;
 
     /**
      * 校验 provider 凭证,返回稳定的 providerUid。
      *
-     * @param provider   apple/google/line
+     * @param provider   apple/google/line_user/line_rider/line_merchant
      * @param credential Apple identityToken / Google idToken / LINE authorization code
      */
     public String verify(String provider, String credential) {
@@ -84,14 +77,15 @@ public class OAuthVerifyService {
             throw new ServiceException(MessageUtils.message("no.oauth.credential.blank"));
         }
         log.info("[OAuth] 校验凭证 provider={}", provider);
-        log.debug("[OAuth] {} credential={}", provider, credential);
         switch (provider) {
             case "apple":
                 return verifyApple(credential);
             case "google":
                 return verifyGoogle(credential);
-            case "line":
-                return verifyLine(credential);
+            case "line_user":
+            case "line_rider":
+            case "line_merchant":
+                return verifyLine(provider, credential);
             default:
                 throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
         }
@@ -100,7 +94,6 @@ public class OAuthVerifyService {
     /** Apple:验 ES256 identityToken,返回 sub(不使用邮箱信息)。 */
     private String verifyApple(String idToken) {
         try {
-            log.debug("[OAuth][Apple] identityToken={}", idToken);
             SignedJWT jwt = SignedJWT.parse(idToken);
             String kid = jwt.getHeader().getKeyID();
             // 拉取 Apple 公钥(JWKS)并按 kid 匹配
@@ -144,25 +137,23 @@ public class OAuthVerifyService {
                 log.warn("[OAuth][Apple] sub 为空");
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
             }
-            log.info("[OAuth][Apple] 校验通过 sub={}", sub);
+            log.info("[OAuth][Apple] 校验通过");
             return sub;
         } catch (ServiceException se) {
             throw se;
         } catch (Exception e) {
-            log.error("[OAuth][Apple] 校验异常", e);
-            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple", e.getMessage()));
+            log.error("[OAuth][Apple] 校验异常 type={}", e.getClass().getSimpleName());
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple"));
         }
     }
 
     /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
     private String verifyGoogle(String idToken) {
         try {
-            log.debug("[OAuth][Google] idToken={}", idToken);
             String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
             JSONObject json = JSONObject.parseObject(httpGet(url, null));
-            log.debug("[OAuth][Google] tokeninfo 返回: {}", json);
             if (json == null || json.containsKey("error") || json.containsKey("error_description")) {
-                log.warn("[OAuth][Google] tokeninfo 返回错误: {}", json);
+                log.warn("[OAuth][Google] tokeninfo 返回错误");
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
             }
             String aud = json.getString("aud");
@@ -175,13 +166,13 @@ public class OAuthVerifyService {
                 log.warn("[OAuth][Google] sub 为空");
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
             }
-            log.info("[OAuth][Google] 校验通过 sub={}", sub);
+            log.info("[OAuth][Google] 校验通过");
             return sub;
         } catch (ServiceException se) {
             throw se;
         } catch (Exception e) {
-            log.error("[OAuth][Google] 校验异常", e);
-            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google", e.getMessage()));
+            log.error("[OAuth][Google] 校验异常 type={}", e.getClass().getSimpleName());
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google"));
         }
     }
 
@@ -195,44 +186,41 @@ public class OAuthVerifyService {
      * @param code LINE 授权码(前端授权后获得,一次性)
      * @return LINE userId
      */
-    private String verifyLine(String code) {
+    private String verifyLine(String provider, String code) {
         try {
-            // code 一次性、短期有效;client_secret 永不记录日志
-            log.info("[OAuth][LINE] 换 token 请求: grant_type=authorization_code, client_id={}, redirect_uri={}, code={}...(len={})",
-                    lineClientId, lineRedirectUri, safePrefix(code), code == null ? 0 : code.length());
-            // 1. code 换 access_token(httpPostForm 内会打印 HTTP 状态码 + LINE 原始响应体,含 error/error_description)
-            JSONObject token = JSONObject.parseObject(httpPostForm(lineTokenUrl, new String[][]{
+            LineOAuthProperties.ResolvedChannel channel = lineOAuthProperties.requireChannel(provider);
+            log.info("[OAuth][LINE] 换 token 请求 provider={}, client_id={}, redirect_uri={}",
+                    provider, channel.clientId(), channel.redirectUri());
+            JSONObject token = JSONObject.parseObject(httpPostForm(lineOAuthProperties.getTokenUrl(), new String[][]{
                     {"grant_type", "authorization_code"},
                     {"code", code},
-                    {"redirect_uri", lineRedirectUri},
-                    {"client_id", lineClientId},
-                    {"client_secret", lineClientSecret}
+                    {"redirect_uri", channel.redirectUri()},
+                    {"client_id", channel.clientId()},
+                    {"client_secret", channel.clientSecret()}
             }));
             if (token == null || token.getString("access_token") == null) {
-                log.warn("[OAuth][LINE] 换 token 失败(响应无 access_token): {}", token);
+                log.warn("[OAuth][LINE] 换 token 失败(响应无 access_token)");
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
             }
             String accessToken = token.getString("access_token");
-            log.info("[OAuth][LINE] 换 token 成功: access_token={}...(len={}), scope={}",
-                    safePrefix(accessToken), accessToken.length(), token.getString("scope"));
-            // 2. access_token 换用户信息(httpGet 内会打印 HTTP 状态码 + profile 原始响应体)
-            JSONObject profile = JSONObject.parseObject(httpGet(lineProfileUrl, accessToken));
+            log.info("[OAuth][LINE] 换 token 成功 provider={}, scope={}", provider, token.getString("scope"));
+            JSONObject profile = JSONObject.parseObject(httpGet(lineOAuthProperties.getProfileUrl(), accessToken));
             if (profile == null) {
                 log.warn("[OAuth][LINE] profile 返回空");
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
             }
             String userId = profile.getString("userId");
             if (userId == null || userId.isEmpty()) {
-                log.warn("[OAuth][LINE] 无 userId: {}", profile);
+                log.warn("[OAuth][LINE] profile 缺少 userId");
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
             }
-            log.info("[OAuth][LINE] 校验通过 userId={}", userId);
+            log.info("[OAuth][LINE] 校验通过 provider={}", provider);
             return userId;
         } catch (ServiceException se) {
             throw se;
         } catch (Exception e) {
-            log.error("[OAuth][LINE] 校验异常", e);
-            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "LINE", e.getMessage()));
+            log.error("[OAuth][LINE] 校验异常 type={}", e.getClass().getSimpleName());
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "LINE"));
         }
     }
 
@@ -258,7 +246,7 @@ public class OAuthVerifyService {
                 } catch (Exception e) {
                     host = url;
                 }
-                log.info("[OAuth][HTTP] GET host={} status={} body={}", host, status, body);
+                log.info("[OAuth][HTTP] GET host={} status={}", host, status);
                 return body;
             }
         }
@@ -281,15 +269,10 @@ public class OAuthVerifyService {
             try (CloseableHttpResponse resp = client.execute(post)) {
                 int status = resp.getStatusLine().getStatusCode();
                 String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
-                // LINE 换 token 失败时 status=400,body 含 error/error_description,是定位根因的关键
-                log.info("[OAuth][HTTP] POST {} status={} body={}", url, status, body);
+                log.info("[OAuth][HTTP] POST {} status={}", url, status);
                 return body;
             }
         }
     }
 
-    /** 取前 8 字符做日志脱敏(code/access_token 不全文打印)。 */
-    private static String safePrefix(String s) {
-        return s == null ? "" : s.substring(0, Math.min(8, s.length()));
-    }
 }

+ 17 - 8
ruoyi-admin/src/main/resources/application.yml

@@ -73,18 +73,27 @@ oauth:
     client-id: com.twanmsdyh.app
     tokeninfo-url: https://oauth2.googleapis.com/tokeninfo
   line:
-    # Channel ID(LINE Developers Console)
-    client-id: "2010911071"
-    # Channel Secret(换 token 用,勿泄露)
-    client-secret: "880fb850c17a3399d207100144a1cb67"
-    # 授权回调地址(须与 LINE Console 回调白名单 + 前端 authorize 一致;GET /auth/line/callback 接住换 token 登录)
-    redirect-uri: https://foodieapi.waimai-paotui.com/auth/line/callback
-    # 后端回调登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/needPhone/error)
-    app-redirect: com.twanmsdyh.app://pages/UserCenter/oauthLogin
     # 用 code 换 access_token 的端点(一般不改)
     token-url: https://api.line.me/oauth2/v2.1/token
     # 用 access_token 换用户信息的端点(一般不改)
     profile-url: https://api.line.me/v2/profile
+    user:
+      client-id: "2010911071"
+      client-secret: "${LINE_USER_CLIENT_SECRET:}"
+      redirect-uri: https://foodieapi.waimai-paotui.com/auth/line/callback?provider=line_user
+      app-redirect: com.twanmsdyh.app://pages/UserCenter/oauthLogin
+    rider:
+      # 恰恰吃骑手;Android 包名 com.twanmsdqs.app;SHA1 61:DC:40:A2:00:9B:EE:9B:9A:09:A7:09:E8:6A:BD:D2:31:E3:7C:D2
+      client-id: "2011397520"
+      client-secret: "${LINE_RIDER_CLIENT_SECRET:}"
+      redirect-uri: https://foodieapi.waimai-paotui.com/auth/line/callback?provider=line_rider
+      app-redirect: com.twanmsdqs.app://pages/UserCenter/oauthLogin
+    merchant:
+      # 恰恰吃商家;Android 包名 com.twanmsdsj.app;SHA1 7D:29:6A:ED:F8:DD:BF:F5:A3:15:3F:6E:A2:7E:D2:22:53:79:D1:77
+      client-id: "2011397463"
+      client-secret: "${LINE_MERCHANT_CLIENT_SECRET:}"
+      redirect-uri: https://foodieapi.waimai-paotui.com/auth/line/callback?provider=line_merchant
+      app-redirect: com.twanmsdsj.app://pages/UserCenter/oauthLogin
 
 # 开发环境配置
 server:

+ 4 - 1
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -72,6 +72,7 @@ no.user.vip.expired=Thành viên hết hạn
 no.user.add=Người dùng mới
 no.user.login.exist=Không thành công, tài khoản đăng nhập đã tồn tại
 no.user.mobile.exist=Thất bại, số điện thoại di động đã tồn tại
+no.user.phone.blank=手机号不能为空
 no.user.password.not.null=Mật khẩu không được để trống
 no.user.jcaptcha.error=CAPTCHA không đúng
 no.user.not.exist=Người dùng không tồn tại
@@ -179,10 +180,12 @@ no.oauth.provider.unsupported=不支持的登录方式:{0}
 no.oauth.needphone=首次登录请验证手机号
 no.oauth.tempkey.missing=登录凭证缺失,请重新登录
 no.oauth.tempkey.expired=登录凭证已过期,请重新登录
+no.oauth.phone.blank=手机号不能为空
+no.oauth.line.config.invalid=LINE登录配置不完整
 no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过
-no.oauth.verify.fail={0}登录校验失败:{1}
+no.oauth.verify.fail={0}登录校验失败
 
 # 订单发票校验(010)
 no.invoice.choice.invalid=发票类型不合法:{0}

+ 4 - 1
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -72,6 +72,7 @@ no.user.vip.expired=Membership has expired
 no.user.add=Add a new user
 no.user.login.exist=Failed, login account already exists
 no.user.mobile.exist=Failed, mobile number already exists
+no.user.phone.blank=Phone number is required
 no.user.password.not.null=Password cannot be empty
 no.user.jcaptcha.error=Incorrect verification code
 no.user.not.exist=User does not exist
@@ -183,10 +184,12 @@ no.oauth.provider.unsupported=Unsupported login method: {0}
 no.oauth.needphone=Phone verification is required for first login
 no.oauth.tempkey.missing=Login credential is missing, please log in again
 no.oauth.tempkey.expired=Login credential expired, please log in again
+no.oauth.phone.blank=Phone number is required
+no.oauth.line.config.invalid=LINE login configuration is incomplete
 no.oauth.token.invalid={0} login credential is invalid
 no.oauth.token.expired={0} login credential has expired
 no.oauth.audience.mismatch={0} credential verification failed
-no.oauth.verify.fail={0} login verification failed: {1}
+no.oauth.verify.fail={0} login verification failed
 
 # Order invoice validation (010)
 no.invoice.choice.invalid=Invalid invoice type: {0}

+ 4 - 1
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -72,6 +72,7 @@ no.user.vip.expired=Thành viên hết hạn
 no.user.add=Người dùng mới
 no.user.login.exist=Không thành công, tài khoản đăng nhập đã tồn tại
 no.user.mobile.exist=Thất bại, số điện thoại di động đã tồn tại
+no.user.phone.blank=Số điện thoại không được để trống
 no.user.password.not.null=Mật khẩu không được để trống
 no.user.jcaptcha.error=CAPTCHA không đúng
 no.user.not.exist=Người dùng không tồn tại
@@ -183,10 +184,12 @@ no.oauth.provider.unsupported=Không hỗ trợ cách đăng nhập: {0}
 no.oauth.needphone=Cần xác minh số điện thoại ở lần đăng nhập đầu
 no.oauth.tempkey.missing=Thiếu thông tin đăng nhập, vui lòng đăng nhập lại
 no.oauth.tempkey.expired=Thông tin đăng nhập đã hết hạn, vui lòng đăng nhập lại
+no.oauth.phone.blank=Số điện thoại không được để trống
+no.oauth.line.config.invalid=Cấu hình đăng nhập LINE chưa đầy đủ
 no.oauth.token.invalid=Thông tin đăng nhập {0} không hợp lệ
 no.oauth.token.expired=Thông tin đăng nhập {0} đã hết hạn
 no.oauth.audience.mismatch=Xác minh thông tin {0} không thành công
-no.oauth.verify.fail=Xác minh đăng nhập {0} thất bại: {1}
+no.oauth.verify.fail=Xác minh đăng nhập {0} thất bại
 
 # Kiểm tra hóa đơn đơn hàng (010)
 no.invoice.choice.invalid=Loại hóa đơn không hợp lệ: {0}

+ 4 - 1
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -72,6 +72,7 @@ no.user.vip.expired=会员已过期
 no.user.add=新增用户
 no.user.login.exist=失败,登录账号已存在
 no.user.mobile.exist=失败,手机号码已存在
+no.user.phone.blank=手机号不能为空
 no.user.password.not.null=密码不能为空
 no.user.jcaptcha.error=验证码不正确
 no.user.not.exist=用户不存在
@@ -183,10 +184,12 @@ no.oauth.provider.unsupported=不支持的登录方式:{0}
 no.oauth.needphone=首次登录请验证手机号
 no.oauth.tempkey.missing=登录凭证缺失,请重新登录
 no.oauth.tempkey.expired=登录凭证已过期,请重新登录
+no.oauth.phone.blank=手机号不能为空
+no.oauth.line.config.invalid=LINE登录配置不完整
 no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过
-no.oauth.verify.fail={0}登录校验失败:{1}
+no.oauth.verify.fail={0}登录校验失败
 
 # 订单发票校验(010)
 no.invoice.choice.invalid=发票类型不合法:{0}

+ 4 - 1
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -72,6 +72,7 @@ no.user.vip.expired=會員已過期
 no.user.add=新增用戶
 no.user.login.exist=失敗,登入帳號已存在
 no.user.mobile.exist=失敗,手機號碼已存在
+no.user.phone.blank=手機號碼不可為空
 no.user.password.not.null=密碼不能為空
 no.user.jcaptcha.error=驗證碼不正確
 no.user.not.exist=用戶不存在
@@ -183,10 +184,12 @@ no.oauth.provider.unsupported=不支援的登入方式:{0}
 no.oauth.needphone=首次登入請驗證手機號
 no.oauth.tempkey.missing=登入憑證缺失,請重新登入
 no.oauth.tempkey.expired=登入憑證已過期,請重新登入
+no.oauth.phone.blank=手機號碼不可為空
+no.oauth.line.config.invalid=LINE登入設定不完整
 no.oauth.token.invalid={0}登入憑證無效
 no.oauth.token.expired={0}登入憑證已過期
 no.oauth.audience.mismatch={0}憑證校驗未通過
-no.oauth.verify.fail={0}登入校驗失敗:{1}
+no.oauth.verify.fail={0}登入校驗失敗
 
 # 訂單發票校驗(010)
 no.invoice.choice.invalid=發票類型不合法:{0}

+ 72 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/StallControllerTest.java

@@ -0,0 +1,72 @@
+package com.ruoyi.app.stall;
+
+import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
+import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
+import com.ruoyi.common.constant.HttpStatus;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.app.user.BusinessPhoneService;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class StallControllerTest {
+
+    @Test
+    void stallOwnerUsesUniqueBusinessTelPhone() throws Exception {
+        IPosStoreService posStoreService = mock(IPosStoreService.class);
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        BusinessPhoneService businessPhoneService = mock(BusinessPhoneService.class);
+        @SuppressWarnings("unchecked")
+        LambdaQueryChainWrapper<InfoUser> nameQuery = mock(LambdaQueryChainWrapper.class);
+        when(infoUserService.lambdaQuery()).thenReturn(nameQuery);
+        when(nameQuery.eq(any(SFunction.class), any())).thenReturn(nameQuery);
+        when(nameQuery.one()).thenReturn(null);
+
+        PosStore store = new PosStore();
+        store.setId(8);
+        store.setIsStall(1);
+        store.setUserId(10L);
+        when(posStoreService.selectPosStoreById(8L)).thenReturn(store);
+
+        StallController controller = new StallController();
+        ReflectionTestUtils.setField(controller, "posStoreService", posStoreService);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "businessPhoneService", businessPhoneService);
+
+        InfoUser request = new InfoUser();
+        request.setStoreId(8L);
+        request.setUserName("stall-owner");
+        request.setTelPhone("0912345678");
+        request.setPassword("password");
+
+        AjaxResult result;
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.success")).thenReturn("ok");
+            result = controller.addStallOwner(
+                    JwtUtil.setToken("10", "night-market"), request);
+        }
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        verify(businessPhoneService).ensureUnique("0912345678", null);
+        ArgumentCaptor<InfoUser> inserted = ArgumentCaptor.forClass(InfoUser.class);
+        verify(infoUserService).insertInfoUser(inserted.capture());
+        assertEquals("0912345678", inserted.getValue().getTelPhone());
+        assertNull(inserted.getValue().getPhone());
+        assertEquals("4", inserted.getValue().getUserType());
+    }
+}

+ 68 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/BusinessPhoneServiceTest.java

@@ -0,0 +1,68 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import java.util.Collection;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class BusinessPhoneServiceTest {
+
+    @BeforeAll
+    static void initializeTableMetadata() {
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), InfoUser.class);
+    }
+
+    @Test
+    void rejectsPhoneUsedByAnotherActiveBusinessAccount() {
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        when(infoUserService.count(any(Wrapper.class))).thenReturn(1L);
+        BusinessPhoneService service = new BusinessPhoneService(infoUserService);
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.user.mobile.exist"))
+                    .thenReturn("手机号已存在");
+
+            assertThrows(ServiceException.class,
+                    () -> service.ensureUnique("0912345678", 42L));
+        }
+
+        ArgumentCaptor<LambdaQueryWrapper<InfoUser>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(infoUserService).count(captor.capture());
+        LambdaQueryWrapper<InfoUser> query = captor.getValue();
+        String sql = query.getSqlSegment();
+        Collection<Object> parameters = query.getParamNameValuePairs().values();
+        assertTrue(sql.contains("tel_phone"));
+        assertTrue(sql.contains("del_flag"));
+        assertTrue(sql.contains("user_type"));
+        assertTrue(sql.contains("user_id"));
+        assertTrue(parameters.contains("0912345678"));
+        assertTrue(parameters.contains("0"));
+        assertTrue(parameters.contains(42L));
+        assertTrue(parameters.contains("1"));
+        assertTrue(parameters.contains("2"));
+        assertTrue(parameters.contains("3"));
+        assertTrue(parameters.contains("4"));
+        assertTrue(parameters.contains("5"));
+    }
+}

+ 241 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.user;
 
+import com.auth0.jwt.JWT;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -7,17 +8,25 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.ruoyi.common.constant.HttpStatus;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.utils.oauth.LineOAuthProperties;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserOauth;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.vo.UserDTO;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IUserWalletService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.common.utils.spring.SpringUtils;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -26,12 +35,16 @@ import org.mockito.MockedStatic;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
 import org.springframework.context.support.StaticMessageSource;
+import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
 
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Locale;
+import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -41,6 +54,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -50,7 +64,12 @@ class InfoUserControllerTest {
     private IPosOrderService posOrderService;
     private IInfoUserService infoUserService;
     private IUserWalletService userWalletService;
+    private MerchantStoreAccessService merchantStoreAccessService;
     private MerchantTokenSessionService merchantTokenSessionService;
+    private BusinessPhoneService businessPhoneService;
+    private LineOAuthProperties lineOAuthProperties;
+    private InfoUserOauthMapper infoUserOauthMapper;
+    private RedisCache redisCache;
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
     @BeforeAll
@@ -68,7 +87,12 @@ class InfoUserControllerTest {
         messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
         messageSource.addMessage("merchant.subaccount.platform.managed", Locale.getDefault(),
                 "分管账号只能通过专用入口管理");
+        messageSource.addMessage("no.oauth.phone.blank", Locale.getDefault(), "手机号不能为空");
+        messageSource.addMessage("no.user.login.success", Locale.getDefault(), "登录成功");
+        messageSource.addMessage("no.user.stop", Locale.getDefault(), "账号已停用");
+        messageSource.addMessage("no.oauth.tempkey.expired", Locale.getDefault(), "登录凭证已过期");
         beanFactory.registerSingleton("messageSource", messageSource);
+        beanFactory.registerSingleton("redisCache", mock(RedisCache.class));
         new SpringUtils().postProcessBeanFactory(beanFactory);
     }
 
@@ -79,15 +103,35 @@ class InfoUserControllerTest {
 
     @BeforeEach
     void setUp() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRemoteAddr("127.0.0.1");
+        request.addHeader("User-Agent", "JUnit");
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
         controller = new TestInfoUserController();
         posOrderService = mock(IPosOrderService.class);
         infoUserService = mock(IInfoUserService.class);
         userWalletService = mock(IUserWalletService.class);
+        merchantStoreAccessService = mock(MerchantStoreAccessService.class);
         merchantTokenSessionService = mock(MerchantTokenSessionService.class);
+        businessPhoneService = mock(BusinessPhoneService.class);
+        lineOAuthProperties = mock(LineOAuthProperties.class);
+        infoUserOauthMapper = mock(InfoUserOauthMapper.class);
+        redisCache = mock(RedisCache.class);
         ReflectionTestUtils.setField(controller, "posOrderService", posOrderService);
         ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
         ReflectionTestUtils.setField(controller, "userWalletService", userWalletService);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchantStoreAccessService);
         ReflectionTestUtils.setField(controller, "merchantTokenSessionService", merchantTokenSessionService);
+        ReflectionTestUtils.setField(controller, "businessPhoneService", businessPhoneService);
+        ReflectionTestUtils.setField(controller, "lineOAuthProperties", lineOAuthProperties);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
+    }
+
+    @AfterEach
+    void clearRequestContext() {
+        RequestContextHolder.resetRequestAttributes();
     }
 
     @Test
@@ -264,6 +308,203 @@ class InfoUserControllerTest {
                 .logoutCurrent("qtw_tokens:sh:app:936:current-session");
     }
 
+    @Test
+    void phoneRegistrationMarksTokenProviderAsPhone() {
+        UserDTO request = new UserDTO();
+        request.setPhone("0912345678");
+        InfoUser created = new InfoUser();
+        created.setUserId(42L);
+        created.setPhone(request.getPhone());
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        when(infoUserService.getuser(request.getPhone())).thenReturn(created);
+
+        AjaxResult result = controller.createUser(request);
+
+        String token = (String) result.get("token");
+        assertEquals("phone", JWT.decode(token).getClaim("provider").asString());
+    }
+
+    @Test
+    void riderRegistrationRejectsDuplicateBusinessPhone() {
+        com.ruoyi.system.domain.vo.UserDTO request = new com.ruoyi.system.domain.vo.UserDTO();
+        request.setTelPhone("0912345678");
+        org.mockito.Mockito.doThrow(new ServiceException("手机号已存在"))
+                .when(businessPhoneService).ensureUnique("0912345678", null);
+
+        assertThrows(ServiceException.class, () -> controller.createQsUser(request));
+
+        verify(infoUserService, never()).insertInfoUser(any(InfoUser.class));
+    }
+
+    @Test
+    void platformEditChecksBusinessPhoneExcludingCurrentAccount() {
+        InfoUser existing = new InfoUser();
+        existing.setUserId(42L);
+        existing.setUserType("2");
+        when(infoUserService.selectInfoUserByUserId(42L)).thenReturn(existing);
+        when(businessPhoneService.isBusinessUserType("2")).thenReturn(true);
+        when(infoUserService.updateInfoUser(any(InfoUser.class))).thenReturn(1);
+        InfoUser request = new InfoUser();
+        request.setUserId(42L);
+        request.setTelPhone("0912345678");
+
+        controller.edit(request);
+
+        verify(businessPhoneService).ensureUnique("0912345678", 42L);
+    }
+
+    @Test
+    void oauthBindPhoneRejectsMissingPhoneWithoutWritingBinding() {
+        OAuthBindDto request = new OAuthBindDto();
+        request.setTempKey("temp-key");
+        when(redisCache.getCacheObject("oauth:bind:temp-key"))
+                .thenReturn("line_user@line-uid");
+        when(redisCache.deleteObject("oauth:bind:temp-key")).thenReturn(true);
+        when(lineOAuthProperties.requireChannel("line_user"))
+                .thenReturn(lineUserChannel());
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        assertEquals("手机号不能为空", result.get(AjaxResult.MSG_TAG));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void lineUserBindingCreatesNormalUserInsteadOfBindingLegacyBusinessPhone() {
+        OAuthBindDto request = new OAuthBindDto();
+        request.setTempKey("temp-key");
+        request.setPhone("0912345678");
+        request.setCode("8888");
+        when(redisCache.getCacheObject("oauth:bind:temp-key"))
+                .thenReturn("line_user@line-uid");
+        when(redisCache.deleteObject("oauth:bind:temp-key")).thenReturn(true);
+        when(lineOAuthProperties.requireChannel("line_user"))
+                .thenReturn(lineUserChannel());
+
+        InfoUser legacyMerchant = activeUser(7L, "1");
+        legacyMerchant.setPhone("0912345678");
+        when(infoUserService.getuser("0912345678")).thenReturn(legacyMerchant);
+
+        InfoUser createdUser = activeUser(99L, "0");
+        createdUser.setPhone("0912345678");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(null, createdUser);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<InfoUserOauth> binding = ArgumentCaptor.forClass(InfoUserOauth.class);
+        verify(infoUserOauthMapper).insert(binding.capture());
+        assertEquals(99L, binding.getValue().getUserId());
+        verify(infoUserService, never()).getuser("0912345678");
+    }
+
+    @Test
+    void profileUpdateCannotChangeOwnRoleOrStatus() {
+        InfoUser current = activeUser(42L, "0");
+        when(infoUserService.getById("42")).thenReturn(current);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        InfoUser request = new InfoUser();
+        request.setUserType("2");
+        request.setStatus("0");
+
+        controller.setuser(tokenFor(42L), request);
+
+        ArgumentCaptor<InfoUser> saved = ArgumentCaptor.forClass(InfoUser.class);
+        verify(infoUserService).saveOrUpdate(saved.capture());
+        assertNull(saved.getValue().getUserType());
+        assertNull(saved.getValue().getStatus());
+    }
+
+    @Test
+    void oauthBindPhoneRejectsTempKeyAlreadyClaimedByAnotherRequest() {
+        OAuthBindDto request = new OAuthBindDto();
+        request.setTempKey("replayed-key");
+        request.setPhone("0912345678");
+        request.setCode("8888");
+        when(redisCache.getCacheObject("oauth:bind:replayed-key"))
+                .thenReturn("line_rider@line-uid");
+        when(redisCache.deleteObject("oauth:bind:replayed-key")).thenReturn(false);
+        when(lineOAuthProperties.requireChannel("line_rider"))
+                .thenReturn(new LineOAuthProperties.ResolvedChannel(
+                        "line_rider", "client", "secret", "redirect", "app-redirect",
+                        com.ruoyi.common.constant.CacheConstants.QS_TOKEN_KEY, Set.of("2"), false));
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(activeUser(22L, "2"));
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void lineMerchantBindingRejectsSubaccountWithUnavailableOwner() {
+        OAuthBindDto request = new OAuthBindDto();
+        request.setTempKey("merchant-key");
+        when(redisCache.getCacheObject("oauth:bind:merchant-key"))
+                .thenReturn("line_merchant@line-uid");
+        when(redisCache.deleteObject("oauth:bind:merchant-key")).thenReturn(true);
+        when(lineOAuthProperties.requireChannel("line_merchant"))
+                .thenReturn(lineMerchantChannel());
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(55L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser subaccount = activeUser(55L, "5");
+        subaccount.setSubaccountStatus("0");
+        when(infoUserService.getById(55L)).thenReturn(subaccount);
+        doThrow(new ServiceException("owner unavailable"))
+                .when(merchantStoreAccessService).resolve(55L);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        assertEquals("账号已停用", result.get(AjaxResult.MSG_TAG));
+    }
+
+    @Test
+    void oauthBindingRaceRejectsDisabledNonLineAccount() {
+        OAuthBindDto request = new OAuthBindDto();
+        request.setTempKey("google-key");
+        when(redisCache.getCacheObject("oauth:bind:google-key"))
+                .thenReturn("google@google-uid");
+        when(redisCache.deleteObject("oauth:bind:google-key")).thenReturn(true);
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(66L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser disabled = activeUser(66L, "0");
+        disabled.setStatus("1");
+        when(infoUserService.getById(66L)).thenReturn(disabled);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        assertEquals("账号已停用", result.get(AjaxResult.MSG_TAG));
+    }
+
+    private LineOAuthProperties.ResolvedChannel lineUserChannel() {
+        return new LineOAuthProperties.ResolvedChannel(
+                "line_user", "client", "secret", "redirect", "app-redirect",
+                com.ruoyi.common.constant.CacheConstants.USER_TOKEN_KEY, Set.of("0"), true);
+    }
+
+    private LineOAuthProperties.ResolvedChannel lineMerchantChannel() {
+        return new LineOAuthProperties.ResolvedChannel(
+                "line_merchant", "client", "secret", "redirect", "app-redirect",
+                com.ruoyi.common.constant.CacheConstants.SH_APP_TOKEN_KEY,
+                Set.of("1", "3", "4", "5"), false);
+    }
+
+    private InfoUser activeUser(Long userId, String userType) {
+        InfoUser user = new InfoUser();
+        user.setUserId(userId);
+        user.setUserType(userType);
+        user.setStatus("0");
+        user.setDelFlag("0");
+        user.setUserName("user-" + userId);
+        return user;
+    }
+
     private String tokenFor(Long userId) {
         return JwtUtil.setToken(String.valueOf(userId), "test-user");
     }

+ 213 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java

@@ -0,0 +1,213 @@
+package com.ruoyi.app.user;
+
+import com.auth0.jwt.JWT;
+import com.ruoyi.app.utils.oauth.LineOAuthProperties;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LineCallbackControllerTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void installJwtRedisBean() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @AfterEach
+    void clearRequestContext() {
+        RequestContextHolder.resetRequestAttributes();
+    }
+
+    @Test
+    void sharedCallbackUsesProviderSpecificChannelAndKeepsProviderInTempBinding() throws Exception {
+        OAuthVerifyService verifyService = mock(OAuthVerifyService.class);
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        InfoUserOauthMapper oauthMapper = mock(InfoUserOauthMapper.class);
+        RedisCache redisCache = mock(RedisCache.class);
+        LineOAuthProperties properties = new LineOAuthProperties();
+        LineOAuthProperties.Channel rider = new LineOAuthProperties.Channel();
+        rider.setClientId("2011397520");
+        rider.setClientSecret("secret");
+        rider.setRedirectUri("https://api.test/auth/line/callback?provider=line_rider");
+        rider.setAppRedirect("com.twanmsdqs.app://pages/UserCenter/oauthLogin");
+        properties.setRider(rider);
+        when(verifyService.verify("line_rider", "code")).thenReturn("line-uid");
+        when(oauthMapper.selectOne(any())).thenReturn(null);
+        LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", verifyService);
+        ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", oauthMapper);
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        controller.callback("line_rider", "code", "state", response);
+
+        assertTrue(response.getRedirectedUrl().startsWith(
+                "com.twanmsdqs.app://pages/UserCenter/oauthLogin?needPhone=1&tempKey="));
+        verify(redisCache).setCacheObject(any(String.class),
+                org.mockito.ArgumentMatchers.eq("line_rider@line-uid"),
+                org.mockito.ArgumentMatchers.eq(5), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void boundRiderReceivesRiderSessionToken() throws Exception {
+        CallbackFixture fixture = boundFixture("2");
+
+        fixture.controller.callback("line_rider", "code", "state", fixture.response);
+
+        String redirected = fixture.response.getRedirectedUrl();
+        String token = URLDecoder.decode(redirected.substring(redirected.indexOf("?token=") + 7),
+                StandardCharsets.UTF_8);
+        assertTrue(JWT.decode(token).getId().startsWith(CacheConstants.QS_TOKEN_KEY + "42:"));
+        assertEquals("line_rider", JWT.decode(token).getClaim("provider").asString());
+        verify(fixture.redisCache).deleteKeys(CacheConstants.QS_TOKEN_KEY + "42:*");
+    }
+
+    @Test
+    void riderChannelRejectsBindingToMerchantAccount() throws Exception {
+        CallbackFixture fixture = boundFixture("1");
+
+        fixture.controller.callback("line_rider", "code", "state", fixture.response);
+
+        assertEquals("com.twanmsdqs.app://pages/UserCenter/oauthLogin?error=user_stopped",
+                fixture.response.getRedirectedUrl());
+        verify(fixture.redisCache, never()).deleteKeys(any(String.class));
+    }
+
+    @Test
+    void merchantCallbackRejectsSubaccountWithUnavailableOwner() throws Exception {
+        OAuthVerifyService verifyService = mock(OAuthVerifyService.class);
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        InfoUserOauthMapper oauthMapper = mock(InfoUserOauthMapper.class);
+        RedisCache redisCache = mock(RedisCache.class);
+        MerchantStoreAccessService accessService = mock(MerchantStoreAccessService.class);
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        beanFactory.registerSingleton("redisCache", redisCache);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRemoteAddr("127.0.0.1");
+        request.addHeader("User-Agent", "JUnit");
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+        LineOAuthProperties properties = new LineOAuthProperties();
+        LineOAuthProperties.Channel merchant = new LineOAuthProperties.Channel();
+        merchant.setClientId("merchant-client");
+        merchant.setClientSecret("secret");
+        merchant.setRedirectUri("https://api.test/auth/line/callback?provider=line_merchant");
+        merchant.setAppRedirect("com.twanmsdsj.app://pages/UserCenter/oauthLogin");
+        properties.setMerchant(merchant);
+        when(verifyService.verify("line_merchant", "code")).thenReturn("line-uid");
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(55L);
+        when(oauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser subaccount = new InfoUser();
+        subaccount.setUserId(55L);
+        subaccount.setUserName("subaccount");
+        subaccount.setUserType("5");
+        subaccount.setStatus("0");
+        subaccount.setDelFlag("0");
+        subaccount.setSubaccountStatus("0");
+        when(infoUserService.getOne(any())).thenReturn(subaccount);
+        doThrow(new ServiceException("owner unavailable"))
+                .when(accessService).resolve(55L);
+        LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", verifyService);
+        ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", oauthMapper);
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", accessService);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        controller.callback("line_merchant", "code", "state", response);
+
+        assertEquals("com.twanmsdsj.app://pages/UserCenter/oauthLogin?error=user_stopped",
+                response.getRedirectedUrl());
+    }
+
+    private CallbackFixture boundFixture(String userType) {
+        OAuthVerifyService verifyService = mock(OAuthVerifyService.class);
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        InfoUserOauthMapper oauthMapper = mock(InfoUserOauthMapper.class);
+        RedisCache redisCache = mock(RedisCache.class);
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        beanFactory.registerSingleton("redisCache", redisCache);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRemoteAddr("127.0.0.1");
+        request.addHeader("User-Agent", "JUnit");
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        LineOAuthProperties properties = new LineOAuthProperties();
+        LineOAuthProperties.Channel rider = new LineOAuthProperties.Channel();
+        rider.setClientId("2011397520");
+        rider.setClientSecret("secret");
+        rider.setRedirectUri("https://api.test/auth/line/callback?provider=line_rider");
+        rider.setAppRedirect("com.twanmsdqs.app://pages/UserCenter/oauthLogin");
+        properties.setRider(rider);
+
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(42L);
+        InfoUser user = new InfoUser();
+        user.setUserId(42L);
+        user.setUserName("rider");
+        user.setUserType(userType);
+        user.setStatus("0");
+        user.setDelFlag("0");
+        when(verifyService.verify("line_rider", "code")).thenReturn("line-uid");
+        when(oauthMapper.selectOne(any())).thenReturn(binding);
+        when(infoUserService.getOne(any())).thenReturn(user);
+
+        LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", verifyService);
+        ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", oauthMapper);
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        return new CallbackFixture(controller, redisCache, new MockHttpServletResponse());
+    }
+
+    private record CallbackFixture(LineCallbackController controller, RedisCache redisCache,
+                                   MockHttpServletResponse response) {
+    }
+}

+ 58 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSubaccountApplicationServiceTest.java

@@ -0,0 +1,58 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.dto.MerchantSubaccountCreateRequest;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IMerchantSubaccountStoreService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MerchantSubaccountApplicationServiceTest {
+
+    @Test
+    void newSubaccountUsesTelPhoneAsTheBusinessPhoneField() {
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        IMerchantSubaccountStoreService relationService = mock(IMerchantSubaccountStoreService.class);
+        MerchantStoreAccessService accessService = mock(MerchantStoreAccessService.class);
+        PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
+        MerchantTokenSessionService tokenSessionService = mock(MerchantTokenSessionService.class);
+        BusinessPhoneService businessPhoneService = mock(BusinessPhoneService.class);
+        MerchantSubaccountApplicationService service = new MerchantSubaccountApplicationService(
+                infoUserService, relationService, accessService, posStoreMapper,
+                tokenSessionService, businessPhoneService);
+        when(accessService.getAccessibleStoreIds(10L)).thenReturn(Set.of(7L));
+        when(infoUserService.getinfouserName("0912345678")).thenReturn(null);
+        doAnswer(invocation -> {
+            InfoUser user = invocation.getArgument(0);
+            user.setUserId(20L);
+            return 1;
+        }).when(infoUserService).insertInfoUser(any(InfoUser.class));
+        when(relationService.selectStoreIdsBySubaccountUserId(20L)).thenReturn(List.of());
+        MerchantSubaccountCreateRequest request = new MerchantSubaccountCreateRequest();
+        request.setPhone("0912345678");
+        request.setName("分店账号");
+        request.setPassword("password");
+        request.setStoreIds(List.of(7L));
+
+        service.create(10L, request);
+
+        ArgumentCaptor<InfoUser> captor = ArgumentCaptor.forClass(InfoUser.class);
+        verify(infoUserService).insertInfoUser(captor.capture());
+        assertEquals("0912345678", captor.getValue().getTelPhone());
+        assertNull(captor.getValue().getPhone());
+        verify(businessPhoneService).ensureUnique("0912345678", null);
+    }
+}

+ 84 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/LineOAuthPropertiesTest.java

@@ -0,0 +1,84 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mockStatic;
+
+class LineOAuthPropertiesTest {
+
+    @Test
+    void resolvesEachLineProviderToItsOwnChannelAndAccountRole() {
+        LineOAuthProperties properties = properties();
+
+        LineOAuthProperties.ResolvedChannel user = properties.requireChannel("line_user");
+        assertEquals("user-client", user.clientId());
+        assertEquals("https://api.test/auth/line/callback?provider=line_user", user.redirectUri());
+        assertEquals(CacheConstants.USER_TOKEN_KEY, user.tokenKey());
+        assertEquals(Set.of("0"), user.userTypes());
+        assertTrue(user.allowCreate());
+
+        LineOAuthProperties.ResolvedChannel rider = properties.requireChannel("line_rider");
+        assertEquals("rider-client", rider.clientId());
+        assertEquals(CacheConstants.QS_TOKEN_KEY, rider.tokenKey());
+        assertEquals(Set.of("2"), rider.userTypes());
+        assertFalse(rider.allowCreate());
+
+        LineOAuthProperties.ResolvedChannel merchant = properties.requireChannel("line_merchant");
+        assertEquals("merchant-client", merchant.clientId());
+        assertEquals(CacheConstants.SH_APP_TOKEN_KEY, merchant.tokenKey());
+        assertEquals(Set.of("1", "3", "4", "5"), merchant.userTypes());
+        assertFalse(merchant.allowCreate());
+    }
+
+    @Test
+    void rejectsLegacyOrUnknownLineProvider() {
+        LineOAuthProperties properties = properties();
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(org.mockito.ArgumentMatchers.anyString(),
+                    org.mockito.ArgumentMatchers.any())).thenReturn("unsupported");
+            assertThrows(ServiceException.class, () -> properties.requireChannel("line"));
+            assertThrows(ServiceException.class, () -> properties.requireChannel("line_other"));
+        }
+    }
+
+    @Test
+    void rejectsChannelWithMissingSecret() {
+        LineOAuthProperties properties = properties();
+        properties.getRider().setClientSecret(" ");
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.oauth.line.config.invalid"))
+                    .thenReturn("invalid config");
+            assertThrows(ServiceException.class,
+                    () -> properties.requireChannel("line_rider"));
+        }
+    }
+
+    private LineOAuthProperties properties() {
+        LineOAuthProperties properties = new LineOAuthProperties();
+        properties.setUser(channel("user"));
+        properties.setRider(channel("rider"));
+        properties.setMerchant(channel("merchant"));
+        return properties;
+    }
+
+    private LineOAuthProperties.Channel channel(String name) {
+        LineOAuthProperties.Channel channel = new LineOAuthProperties.Channel();
+        channel.setClientId(name + "-client");
+        channel.setClientSecret(name + "-secret");
+        channel.setRedirectUri("https://api.test/auth/line/callback?provider=line_" + name);
+        channel.setAppRedirect("com.test." + name + "://pages/UserCenter/oauthLogin");
+        return channel;
+    }
+}

+ 1 - 1
ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/LoginUserDto.java

@@ -34,6 +34,6 @@ public class LoginUserDto {
     /** 登录时间 */
     private Long loginTime;
 
-    /** 本次登录渠道 apple/google/line/phone(三方登录写入,用于登录方式标记) */
+    /** 本次登录渠道 apple/google/line_user/line_rider/line_merchant/phone */
     private String provider;
 }

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/utils/JwtUtil.java

@@ -63,7 +63,7 @@ public class JwtUtil {
                     .withExpiresAt(expireDate) //设置签名过期的时间
                     .withClaim("id", user.getUserId() != null ? String.valueOf(user.getUserId()) : null) //自定义信息,将Long转换为String
                     .withClaim("userName", user.getUserName())//自定义信息
-                    .withClaim("provider", user.getProvider())//登录渠道 apple/google/line/phone(三方登录标记)
+                    .withClaim("provider", user.getProvider())//登录渠道 apple/google/line_user/line_rider/line_merchant/phone
                     .withJWTId(jti) //jwt的唯一标识符,每个token都有唯一的JTI,用于防止重放攻击和token撤销
                     .sign(algorithm);
             redisCache.setCacheObject(jti, user, (int)EXPIRE_DATE, TimeUnit.MILLISECONDS);

+ 307 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplDiscountLimitTest.java

@@ -0,0 +1,307 @@
+package com.ruoyi.system.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.domain.PromotionUserCoupon;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionCalcResponse.AvailableCoupon;
+import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PromotionActivityMapper;
+import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
+import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
+import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
+import com.ruoyi.system.mapper.PromotionUserCouponMapper;
+
+@ExtendWith(MockitoExtension.class)
+class PromotionCalcServiceImplDiscountLimitTest
+{
+    private static final Long USER_ID = 9L;
+    private static final Long STORE_ID = 1L;
+    private static final Long COUPON_ID = 100L;
+    private static final Long BATCH_ID = 200L;
+
+    @Mock
+    private PromotionActivityMapper activityMapper;
+
+    @Mock
+    private PromotionActivityRuleMapper activityRuleMapper;
+
+    @Mock
+    private PromotionCouponBatchMapper couponBatchMapper;
+
+    @Mock
+    private PromotionCouponRuleMapper couponRuleMapper;
+
+    @Mock
+    private PromotionUserCouponMapper userCouponMapper;
+
+    @Mock
+    private PosFoodMapper posFoodMapper;
+
+    @Mock
+    private PosOrderMapper posOrderMapper;
+
+    @Mock
+    private PromotionDiscountLimitChecker discountLimitChecker;
+
+    @InjectMocks
+    private PromotionCalcServiceImpl service;
+
+    @BeforeEach
+    void setUp()
+    {
+        when(posFoodMapper.selectById(1L)).thenReturn(food("100"));
+        when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(Collections.emptyList());
+        when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(Collections.emptyList());
+        when(posOrderMapper.selectCount(any())).thenReturn(1L);
+        when(userCouponMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(discountLimitChecker.isEnabled()).thenReturn(true);
+        lenient().when(discountLimitChecker.getRatioPercent()).thenReturn(decimal("20"));
+    }
+
+    @Test
+    void calculate_shouldCapCouponAtTwentyPercentOfOriginalAmount()
+    {
+        stubSelectedCoupon("30", 0);
+
+        PromotionCalcResponse response = service.calculate(request(COUPON_ID), USER_ID);
+
+        assertAmount("100", response.getOriginalAmount());
+        assertAmount("20", response.getCouponReduce());
+        assertAmount("80", response.getFinalAmount());
+        PromotionDetail couponDetail = findDetail(response, "coupon");
+        assertAmount("20", couponDetail.getReduce());
+        assertTrue(couponDetail.getLimitCapped());
+    }
+
+    @Test
+    void calculate_shouldNotFlagCouponAtExactBudgetLimit()
+    {
+        stubSelectedCoupon("20", 0);
+
+        PromotionCalcResponse response = service.calculate(request(COUPON_ID), USER_ID);
+
+        assertAmount("20", response.getCouponReduce());
+        assertAmount("80", response.getFinalAmount());
+        assertNull(findDetail(response, "coupon").getLimitCapped());
+    }
+
+    @Test
+    void calculate_shouldGivePromotionFirstClaimOnSharedBudget()
+    {
+        PromotionActivity activity = fullReductionActivity();
+        PromotionActivityRule activityRule = fullReductionRule("10");
+        when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(List.of(activity));
+        when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(List.of(activityRule));
+        stubSelectedCoupon("15", 0);
+
+        PromotionCalcResponse response = service.calculate(request(COUPON_ID), USER_ID);
+
+        assertAmount("10", findDetail(response, "promotion").getReduce());
+        assertAmount("10", response.getCouponReduce());
+        assertAmount("80", response.getFinalAmount());
+        assertTrue(findDetail(response, "coupon").getLimitCapped());
+    }
+
+    @Test
+    void calculate_shouldUseConfiguredRatio()
+    {
+        when(discountLimitChecker.getRatioPercent()).thenReturn(decimal("30"));
+        stubSelectedCoupon("40", 0);
+
+        PromotionCalcResponse response = service.calculate(request(COUPON_ID), USER_ID);
+
+        assertAmount("30", response.getCouponReduce());
+        assertAmount("70", response.getFinalAmount());
+        assertTrue(findDetail(response, "coupon").getLimitCapped());
+    }
+
+    @Test
+    void calculate_shouldKeepOriginalDiscountWhenLimitIsDisabled()
+    {
+        when(discountLimitChecker.isEnabled()).thenReturn(false);
+        stubSelectedCoupon("30", 0);
+
+        PromotionCalcResponse response = service.calculate(request(COUPON_ID), USER_ID);
+
+        assertAmount("30", response.getCouponReduce());
+        assertAmount("70", response.getFinalAmount());
+        assertNull(findDetail(response, "coupon").getLimitCapped());
+    }
+
+    @Test
+    void calculate_shouldGiveMutexCouponAFullFreshBudget()
+    {
+        PromotionActivity activity = fullReductionActivity();
+        PromotionActivityRule activityRule = fullReductionRule("10");
+        when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(List.of(activity));
+        when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(List.of(activityRule));
+        stubSelectedCoupon("30", 1);
+
+        PromotionCalcResponse response = service.calculate(request(COUPON_ID), USER_ID);
+
+        assertTrue(response.getCouponConflict());
+        assertAmount("20", response.getCouponReduce());
+        assertAmount("80", response.getFinalAmount());
+        assertFalse(response.getDetails().stream().anyMatch(detail -> "promotion".equals(detail.getType())));
+        assertTrue(findDetail(response, "coupon").getLimitCapped());
+    }
+
+    @Test
+    void calculate_shouldMarkCandidateCouponUnusableWhenPromotionExhaustsBudget()
+    {
+        PromotionActivity activity = fullReductionActivity();
+        PromotionActivityRule activityRule = fullReductionRule("20");
+        when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(List.of(activity));
+        when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(List.of(activityRule));
+
+        PromotionUserCoupon userCoupon = new PromotionUserCoupon();
+        userCoupon.setId(COUPON_ID);
+        userCoupon.setUserId(USER_ID);
+        userCoupon.setBatchId(BATCH_ID);
+        userCoupon.setStoreId(STORE_ID);
+        userCoupon.setStatus(0);
+        userCoupon.setExpireTime(new Date(System.currentTimeMillis() + 86_400_000L));
+
+        PromotionCouponBatch batch = new PromotionCouponBatch();
+        batch.setId(BATCH_ID);
+        batch.setStoreId(STORE_ID);
+        batch.setName("候选优惠券");
+        batch.setCouponType(1);
+        batch.setStatus(1);
+
+        PromotionCouponRule couponRule = new PromotionCouponRule();
+        couponRule.setBatchId(BATCH_ID);
+        couponRule.setIsMutex(0);
+        couponRule.setThreshold(BigDecimal.ZERO);
+        couponRule.setAmount(decimal("10"));
+
+        when(userCouponMapper.selectList(any())).thenReturn(List.of(userCoupon));
+        when(couponBatchMapper.selectById(BATCH_ID)).thenReturn(batch);
+        when(couponRuleMapper.selectRuleByBatchId(BATCH_ID)).thenReturn(couponRule);
+
+        PromotionCalcResponse response = service.calculate(request(null), USER_ID);
+
+        AvailableCoupon candidate = response.getAvailableCoupons().get(0);
+        assertFalse(candidate.getUsable());
+        assertAmount("0", candidate.getCouponPreviewReduce());
+        assertNotNull(candidate.getUnusableReason());
+    }
+
+    private void stubSelectedCoupon(String amount, int isMutex)
+    {
+        PromotionUserCoupon userCoupon = new PromotionUserCoupon();
+        userCoupon.setId(COUPON_ID);
+        userCoupon.setUserId(USER_ID);
+        userCoupon.setBatchId(BATCH_ID);
+        userCoupon.setStoreId(STORE_ID);
+        userCoupon.setStatus(0);
+        userCoupon.setExpireTime(new Date(System.currentTimeMillis() + 86_400_000L));
+
+        PromotionCouponBatch batch = new PromotionCouponBatch();
+        batch.setId(BATCH_ID);
+        batch.setStoreId(STORE_ID);
+        batch.setName("测试优惠券");
+        batch.setCouponType(1);
+        batch.setStatus(1);
+
+        PromotionCouponRule rule = new PromotionCouponRule();
+        rule.setBatchId(BATCH_ID);
+        rule.setIsMutex(isMutex);
+        rule.setThreshold(BigDecimal.ZERO);
+        rule.setAmount(decimal(amount));
+
+        when(userCouponMapper.selectById(COUPON_ID)).thenReturn(userCoupon);
+        when(couponBatchMapper.selectById(BATCH_ID)).thenReturn(batch);
+        when(couponRuleMapper.selectRuleByBatchId(BATCH_ID)).thenReturn(rule);
+    }
+
+    private PromotionCalcRequest request(Long couponId)
+    {
+        PromotionCalcRequest.CartItem item = new PromotionCalcRequest.CartItem();
+        item.setProductId(1L);
+        item.setQuantity(1);
+        item.setSpecPrice(BigDecimal.ZERO);
+
+        PromotionCalcRequest request = new PromotionCalcRequest();
+        request.setStoreId(STORE_ID);
+        request.setItems(List.of(item));
+        request.setCouponId(couponId);
+        return request;
+    }
+
+    private PosFood food(String price)
+    {
+        PosFood food = new PosFood();
+        food.setId(1L);
+        food.setName("测试商品");
+        food.setPrice(decimal(price));
+        return food;
+    }
+
+    private PromotionActivity fullReductionActivity()
+    {
+        PromotionActivity activity = new PromotionActivity();
+        activity.setId(10L);
+        activity.setStoreId(STORE_ID);
+        activity.setType(1);
+        activity.setName("满减活动");
+        activity.setStatus(1);
+        return activity;
+    }
+
+    private PromotionActivityRule fullReductionRule(String reduceAmount)
+    {
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setId(11L);
+        rule.setActivityId(10L);
+        rule.setThreshold(decimal("100"));
+        rule.setReduceAmount(decimal(reduceAmount));
+        return rule;
+    }
+
+    private PromotionDetail findDetail(PromotionCalcResponse response, String type)
+    {
+        return response.getDetails().stream()
+                .filter(detail -> type.equals(detail.getType()))
+                .findFirst()
+                .orElseThrow();
+    }
+
+    private void assertAmount(String expected, BigDecimal actual)
+    {
+        assertEquals(0, decimal(expected).compareTo(actual));
+    }
+
+    private BigDecimal decimal(String value)
+    {
+        return new BigDecimal(value);
+    }
+}

+ 100 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImplDiscountLimitTest.java

@@ -0,0 +1,100 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
+import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
+import com.ruoyi.system.service.ISysConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PromotionCouponBatchServiceImplDiscountLimitTest {
+
+    @Mock
+    private PromotionCouponBatchMapper batchMapper;
+
+    @Mock
+    private PromotionCouponRuleMapper ruleMapper;
+
+    @Mock
+    private ISysConfigService configService;
+
+    @Mock
+    private PosFoodMapper posFoodMapper;
+
+    private PromotionCouponBatchServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        when(configService.selectConfigByKey("promotion.discount.limit.enabled"))
+                .thenReturn("true");
+        when(configService.selectConfigByKey("promotion.discount.limit.ratio"))
+                .thenReturn("20");
+        PromotionDiscountLimitChecker checker = new PromotionDiscountLimitChecker();
+        ReflectionTestUtils.setField(checker, "configService", configService);
+        ReflectionTestUtils.setField(checker, "posFoodMapper", posFoodMapper);
+        service = new PromotionCouponBatchServiceImpl();
+        ReflectionTestUtils.setField(service, "promotionCouponBatchMapper", batchMapper);
+        ReflectionTestUtils.setField(service, "ruleMapper", ruleMapper);
+        ReflectionTestUtils.setField(service, "discountLimitChecker", checker);
+    }
+
+    @Test
+    void createRejectsOverLimitCouponBeforeAnyDatabaseWrite() {
+        PromotionCouponBatch batch = batch(null, 1, 100, 0);
+        PromotionCouponRule rule = fullReductionRule("100", "21");
+
+        assertThrows(ServiceException.class, () -> service.createBatch(batch, rule));
+
+        verifyNoInteractions(batchMapper, ruleMapper);
+    }
+
+    @Test
+    void unreceivedCouponUpdateRejectsOverLimitRuleBeforeReplacingStoredData() {
+        PromotionCouponBatch existing = batch(7L, 1, 100, 0);
+        existing.setStatus(0);
+        existing.setRemainCount(100);
+        when(batchMapper.selectById(7L)).thenReturn(existing);
+        PromotionCouponBatch update = batch(7L, null, 100, null);
+
+        assertThrows(ServiceException.class,
+                () -> service.updateBatch(update, fullReductionRule("100", "21")));
+
+        verify(batchMapper).selectById(7L);
+        verify(batchMapper, never()).updateById(any(PromotionCouponBatch.class));
+        verifyNoInteractions(ruleMapper);
+    }
+
+    private PromotionCouponBatch batch(Long id, Integer type, Integer totalCount,
+                                       Integer receivedCount) {
+        PromotionCouponBatch batch = new PromotionCouponBatch();
+        batch.setId(id);
+        batch.setCouponType(type);
+        batch.setTotalCount(totalCount);
+        batch.setReceivedCount(receivedCount);
+        return batch;
+    }
+
+    private PromotionCouponRule fullReductionRule(String threshold, String amount) {
+        PromotionCouponRule rule = new PromotionCouponRule();
+        rule.setThreshold(new BigDecimal(threshold));
+        rule.setAmount(new BigDecimal(amount));
+        return rule;
+    }
+}

+ 162 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionDiscountLimitCheckerTest.java

@@ -0,0 +1,162 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.service.ISysConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.reset;
+
+@ExtendWith(MockitoExtension.class)
+class PromotionDiscountLimitCheckerTest {
+
+    @Mock
+    private ISysConfigService configService;
+
+    @Mock
+    private PosFoodMapper posFoodMapper;
+
+    @InjectMocks
+    private PromotionDiscountLimitChecker checker;
+
+    @BeforeEach
+    void enableTwentyPercentLimit() {
+        when(configService.selectConfigByKey("promotion.discount.limit.enabled"))
+                .thenReturn("true");
+        when(configService.selectConfigByKey("promotion.discount.limit.ratio"))
+                .thenReturn("20");
+    }
+
+    @Test
+    void fullReductionCouponAllowsExactLimitAndRejectsAmountAboveLimit() {
+        PromotionCouponBatch batch = batch(1);
+
+        assertDoesNotThrow(() -> checker.checkCouponBatch(batch,
+                rule(null, "100", "20", null)));
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch,
+                rule(null, "100", "20.01", null)));
+    }
+
+    @Test
+    void productDiscountCouponAllowsEightTenthsAndRejectsLowerRate() {
+        PromotionCouponBatch batch = batch(2);
+
+        assertDoesNotThrow(() -> checker.checkCouponBatch(batch,
+                rule(1L, null, null, "0.80")));
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch,
+                rule(1L, null, null, "0.79")));
+    }
+
+    @Test
+    void productVoucherUsesCurrentProductPriceAsLimitAnchor() {
+        when(posFoodMapper.selectById(1L)).thenReturn(food(1L, "100"));
+        PromotionCouponBatch batch = batch(2);
+
+        assertDoesNotThrow(() -> checker.checkCouponBatch(batch,
+                rule(1L, null, "20", null)));
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch,
+                rule(1L, null, "20.01", null)));
+    }
+
+    @Test
+    void productVoucherDoesNotRoundNinetyNineDollarLimitUpToTwenty() {
+        when(posFoodMapper.selectById(1L)).thenReturn(food(1L, "99"));
+
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch(2),
+                rule(1L, null, "20", null)));
+    }
+
+    @Test
+    void customThirtyPercentRatioChangesAllowedVoucherAmount() {
+        when(configService.selectConfigByKey("promotion.discount.limit.ratio"))
+                .thenReturn("30");
+        when(posFoodMapper.selectById(1L)).thenReturn(food(1L, "100"));
+        PromotionCouponBatch batch = batch(2);
+
+        assertDoesNotThrow(() -> checker.checkCouponBatch(batch,
+                rule(1L, null, "30", null)));
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch,
+                rule(1L, null, "30.01", null)));
+    }
+
+    @Test
+    void invalidRatioFallsBackToTwentyPercent() {
+        when(configService.selectConfigByKey("promotion.discount.limit.ratio"))
+                .thenReturn("not-a-number");
+
+        assertEquals(new BigDecimal("20"), checker.getRatioPercent());
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch(1),
+                rule(null, "100", "21", null)));
+    }
+
+    @Test
+    void disabledLimitPreservesPreviousCouponBehavior() {
+        reset(configService);
+        when(configService.selectConfigByKey("promotion.discount.limit.enabled"))
+                .thenReturn("false");
+
+        assertDoesNotThrow(() -> checker.checkCouponBatch(batch(1),
+                rule(null, "100", "90", null)));
+    }
+
+    @Test
+    void freeDeliveryCouponRequiresPositiveThreshold() {
+        assertThrows(ServiceException.class, () -> checker.checkCouponBatch(batch(3),
+                rule(null, null, "10", null)));
+    }
+
+    @Test
+    void multiProductCouponRejectsBatchWhenAnyProductExceedsLimit() {
+        when(posFoodMapper.selectById(1L)).thenReturn(food(1L, "100"));
+        when(posFoodMapper.selectById(2L)).thenReturn(food(2L, "50"));
+        List<PromotionCouponRule> rules = List.of(
+                rule(1L, null, "20", null),
+                rule(2L, null, "11", null));
+
+        assertThrows(ServiceException.class,
+                () -> checker.checkCouponBatch(batch(2), rules));
+    }
+
+    private PromotionCouponBatch batch(int type) {
+        PromotionCouponBatch batch = new PromotionCouponBatch();
+        batch.setCouponType(type);
+        return batch;
+    }
+
+    private PromotionCouponRule rule(Long productId, String threshold, String amount,
+                                      String discountRate) {
+        PromotionCouponRule rule = new PromotionCouponRule();
+        rule.setProductId(productId);
+        rule.setThreshold(decimal(threshold));
+        rule.setAmount(decimal(amount));
+        rule.setDiscountRate(decimal(discountRate));
+        return rule;
+    }
+
+    private PosFood food(Long id, String price) {
+        PosFood food = new PosFood();
+        food.setId(id);
+        food.setName("food-" + id);
+        food.setPrice(new BigDecimal(price));
+        return food;
+    }
+
+    private BigDecimal decimal(String value) {
+        return value == null ? null : new BigDecimal(value);
+    }
+}

+ 3 - 0
specs/008-promotion-coupon/tasks.md

@@ -332,6 +332,9 @@
 ### 验证
 
 - [x] T069 JDK21 编译通过 + review 子代理逐点核对:6 个挂接点、半价豁免分支、互斥券路径、预算对明细/快照一致性、开关关闭时与现状逐行为一致(含下架/删除等旁路不受影响)
+- [x] T070 新增 `PromotionDiscountLimitCheckerTest`:覆盖开关、默认/自定义比例、满减券、商品折扣券、商品抵用券、免配送费券和多商品任一超限。
+- [x] T071 新增 `PromotionCouponBatchServiceImplDiscountLimitTest`:覆盖创建与未领取修改的真实拦截结果,证明超限时不落批次/规则数据。
+- [x] T072 新增 `PromotionCalcServiceImplDiscountLimitTest`:覆盖20%总预算、促销占用剩余预算、候选券不可用、自定义比例、开关关闭及金额边界;使用 JDK21 运行定向测试(18/18)和 `ruoyi-system` 全量测试(71/71)。
 
 **Checkpoint**: 开关启用后创建侧拦截超比例设置、算价侧总优惠 ≤ 比例×商品原价;关闭开关全链路回到现状
 

+ 5 - 0
specs/015-intl-flight/spec.md

@@ -1,5 +1,10 @@
 # Feature Specification: 国际机票(盘合 iFlight 分销接入)
 
+> [!IMPORTANT]
+> **需求状态:已作废**
+>
+> 本需求已于 2026-09-04 确认作废,不再进行规划、实现或验收。本文档仅保留为历史记录。
+
 **Feature Branch**: `(待创建,暂未建分支/未提交)`
 
 **Created**: 2026-07-29

+ 32 - 19
specs/017-oauth-login/line-callback-frontend.md

@@ -1,21 +1,31 @@
 # LINE 登录回调 — 前端(uniapp)接入说明
 
-> 配套后端:`LineCallbackController` → `GET /auth/line/callback`(2026-08-05 增量,017-oauth-login FR-009)。
+> 配套后端:`LineCallbackController` → `GET /auth/line/callback`(017-oauth-login FR-009~FR-015)。
 > 适用场景:**「唤起 LINE App / 系统浏览器」** 授权 —— 这种方式前端拿不到 code,由后端接住 LINE 的重定向、完成登录后,再 302 跳回 App。
 >
-> 若你的场景是「H5 / 自家 webview / LINE SDK」(前端能自己拿到 code),**不走本回调**,直接调 `POST /infouser/user/oauthLogin {provider:"line", credential:code}`。两条流程并存。
+> 若你的场景是「H5 / 自家 webview / LINE SDK」(前端能自己拿到 code),**不走本回调**,直接调 `POST /infouser/user/oauthLogin`,provider 仍按客户端传 `line_user`、`line_rider` 或 `line_merchant`。两条流程并存。
+
+## 0. 三端固定配置
+
+| 客户端 | provider | LINE Channel ID | redirect_uri | App 回跳 scheme |
+|---|---|---:|---|---|
+| 普通用户 App | `line_user` | `2010911071` | `https://foodieapi.waimai-paotui.com/auth/line/callback?provider=line_user` | `com.twanmsdyh.app://pages/UserCenter/oauthLogin` |
+| 骑手 App | `line_rider` | `2011397520` | `https://foodieapi.waimai-paotui.com/auth/line/callback?provider=line_rider` | `com.twanmsdqs.app://pages/UserCenter/oauthLogin` |
+| 商家 App | `line_merchant` | `2011397463` | `https://foodieapi.waimai-paotui.com/auth/line/callback?provider=line_merchant` | `com.twanmsdsj.app://pages/UserCenter/oauthLogin` |
+
+每个客户端必须使用本行的 Channel ID、provider、完整 redirect_uri 和 scheme,不能交叉混用。Channel Secret 仅配置在服务端环境变量中,前端不得保存或传输。
 
 ---
 
 ## 1. 整体流程
 
 ```
-① uniapp 打开 LINE 授权页(用 redirect_uri = https://api.awayqtw.com/auth/line/callback)
+① uniapp 按上表打开本客户端的 LINE 授权页
 ② 用户在 LINE App 里一键同意
-③ LINE 跳转:GET https://api.awayqtw.com/auth/line/callback?code=xxx&state=xxx
-④ 后端:code → 换 token → 取 userId → 查绑定 → 签 JWT 或 生成 tempKey
+③ LINE 跳转:GET /auth/line/callback?provider=<本端provider>&code=xxx&state=xxx
+④ 后端:provider 选 Channel → code 换 token → 取 userId → 查绑定 → 签 JWT 或生成 tempKey
 ⑤ 后端 302 跳回 App scheme:
-     com.twanmsdyh.app://oauthLogin?<参数>
+     <本端App回跳scheme>?<参数>
 ⑥ uniapp 被 scheme 拉起,读参数,按下面三种情况处理
 ```
 
@@ -25,7 +35,7 @@
 
 ## 2. scheme 注册(manifest.json)
 
-App 端要能被 `com.twanmsdyh.app://...` 拉起,需在 `manifest.json` 注册该 URL Scheme(HBuilderX:App 模块配置 → App 常用其它设置 / 各平台):
+App 端需在 `manifest.json` 注册上表对应的 URL Scheme(HBuilderX:App 模块配置 → App 常用其它设置 / 各平台)。以下以普通用户 App 为例;骑手、商家分别替换为 `com.twanmsdqs.app`、`com.twanmsdsj.app`
 
 ```json
 {
@@ -44,13 +54,13 @@ App 端要能被 `com.twanmsdyh.app://...` 拉起,需在 `manifest.json` 注
 
 ## 3. 打开 LINE 授权页
 
-前端用系统/LINE 打开如下 URL(`redirect_uri` **必须**与 LINE Console 白名单、后端 `application.yml` 三方一致)
+前端用系统/LINE 打开如下 URL。以下以骑手 App 为例;其他客户端按“0. 三端固定配置”替换 `client_id` 和 `redirect_uri`
 
 ```
 https://access.line.me/oauth2/v2.1/authorize
   ?response_type=code
-  &client_id=2010911071
-  &redirect_uri=https%3A%2F%2Fapi.awayqtw.com%2Fauth%2Fline%2Fcallback
+  &client_id=2011397520
+  &redirect_uri=https%3A%2F%2Ffoodieapi.waimai-paotui.com%2Fauth%2Fline%2Fcallback%3Fprovider%3Dline_rider
   &scope=profile%20openid
   &state=<前端生成的随机串>
 ```
@@ -58,7 +68,8 @@ https://access.line.me/oauth2/v2.1/authorize
 打开方式(uniapp App 端):`plus.runtime.openURL(authorizeUrl)` 或 `plus.share.launchLaunch` / 系统 webview,交由 LINE App / 系统浏览器接管。
 
 - `scope=profile openid`:后端 `v2/profile` 取 userId 要 `profile` 权限,**不能少**。
-- `state`:前端随机生成,用于防 CSRF(后端当前**未强校验** state,但建议传,后续会加)。
+- `redirect_uri`:必须包含本端 provider 查询参数,并把 `?`、`=` 等字符一起 URL 编码;后端换 token 时会提交同一个完整地址。
+- `state`:前端随机生成,用于防 CSRF(后端当前**未强校验** state,但必须继续传,留待联调后续增强)。
 
 ---
 
@@ -81,7 +92,7 @@ export default {
   },
   methods: {
     handleLineCallback(url) {
-      if (!url || !url.startsWith('com.twanmsdyh.app://oauthLogin')) return
+      if (!url || !url.startsWith('com.twanmsdyh.app://pages/UserCenter/oauthLogin')) return
       const queryStr = url.split('?')[1] || ''
       const params = this.parseQuery(queryStr)
       routeByLineParams(params)
@@ -104,7 +115,7 @@ export default {
 
 ## 5. 三种返回值处理(核心)
 
-后端 302 回的 URL 形如 `com.twanmsdyh.app://oauthLogin?<参数>`,参数只有下列三种组合之一:
+后端 302 回的 URL 形如 `<本端App回跳scheme>?<参数>`,参数只有下列三种组合之一:
 
 | 情况 | 参数 | 含义 | 前端动作 |
 |------|------|------|----------|
@@ -190,17 +201,19 @@ if (params.error) {
   }
   ```
   → 存 `token` → 跳首页(同 5.1)。
-- 失败:`code != 200`,常见 `msg`:短信码错误(`no.user.jcaptcha.error`)、账号停用(`no.user.stop`)、tempKey 过期(`no.oauth.tempkey.expired`,需重新走一遍 LINE 登录拿新 tempKey)
+- 失败:`code != 200`,常见 `msg`:短信码错误(`no.user.jcaptcha.error`)、账号停用(`no.user.stop`)、tempKey 过期(`no.oauth.tempkey.expired`)。`tempKey` 在绑定请求开始时会被原子消费,无论后续短信或账号校验是否成功,失败后都必须重新走一遍 LINE 登录取得新 `tempKey`,不能原请求重试
 
-> 绑定成功后 `info_user_oauth(user_id, provider="line", provider_uid=<LINE userId>)` 已写入;**下次同一 LINE 登录就走 5.1 的 `token` 分支**,不再要绑手机。
+> 绑定成功后 `info_user_oauth` 会写入当前客户端 provider(`line_user` / `line_rider` / `line_merchant`)和 LINE userId;**下次同一客户端、同一 LINE 账号登录就走 5.1 的 `token` 分支**,不再要求绑定手机。
+>
+> `line_user` 可关联或新建普通用户;`line_rider` 只能关联已有骑手(`userType=2`);`line_merchant` 只能关联已有商家/夜市/子账号(`userType=1/3/4/5`)。骑手和商家不会在此接口自动创建,绑定手机号读取 `info_user.tel_phone`。
 
 ---
 
 ## 7. 注意事项
 
-1. **redirect_uri 三方一致铁律**:前端 authorize 里的 `redirect_uri`、后端 `application.yml` 的 `oauth.line.redirect-uri`、LINE Console 回调白名单,三者必须**完全相同**(当前 = `https://api.awayqtw.com/auth/line/callback`),否则 LINE 回 `400 redirect_uri_mismatch`。
-2. **tempKey 有效期 5 分钟**:超时后 `/oauthBindPhone` 回 `tempkey.expired`,需重新点 LINE 登录拿新的。
+1. **redirect_uri 三方一致铁律**:每个客户端 authorize 里的 `redirect_uri`、后端该 Channel 的 `redirect-uri`、LINE Console 回调白名单必须**完全相同**,包括 `?provider=line_xxx` 查询参数,否则 LINE 会返回 `400 redirect_uri_mismatch`。
+2. **tempKey 一次性且有效期 5 分钟**:首次 `/oauthBindPhone` 请求会原子消费;超时、重复使用或绑定校验失败后均需重新点 LINE 登录取得新的。
 3. **302 到自定义 scheme 的兼容性**:标准系统浏览器会跟随 302 到 `com.twanmsdyh.app://...` 拉起 App。若联调发现 **LINE 内置浏览器**不跟随 302(App 没被拉起),后端可改为返回 HTML(`meta refresh` + 手动「打开 App」链接)兜底 —— 这是待联调确认项,目前是 302。
 4. **设备信息**:后端回调链路没有 App 上下文,`cid` / `deviceToken` / `voIPToken` 在回调时更新不到;绑手机走 `/oauthBindPhone` 时会带上,已绑定的老用户建议 5.1 拿到 token 后补报一次(或复用既有设备上报逻辑)。
-5. **state 暂未校验**:后端目前不验证 `state`,前端仍建议生成并传,后续后端会加 CSRF 校验
-6. **与 `/oauthLogin` 并存**:如果某端(如 H5)前端能自己拿到 code,直接 `POST /infouser/user/oauthLogin {provider:"line", credential:code, ...}`,不必走本回调;返回值结构与本回调的 `token` / `needPhone+tempKey` 对应一致。
+5. **state 联调安全待办**:后端目前不验证 `state`。完整修复需要新增服务端 state 签发/消费,并由三个 App 保存发起值、在回跳时比对,不能只改单侧回调;当前前端仍须生成并透传,三端联调时统一升级该协议
+6. **与 `/oauthLogin` 并存**:如果某端(如 H5)前端能自己拿到 code,直接 `POST /infouser/user/oauthLogin {provider:"line_user|line_rider|line_merchant", credential:code, ...}`,不必走本回调;provider 必须与生成 code 时使用的 Channel 一致,返回值结构与本回调的 `token` / `needPhone+tempKey` 对应一致。

+ 40 - 5
specs/017-oauth-login/plan.md

@@ -14,7 +14,7 @@ provider 凭证 ──▶ OAuthVerifyService.verify(provider, credential) ──
                 ┌───────────────────────────────────────────────────────┘
                 ▼  (前端引导输手机号 → getcode 发短信)
         oauthBindPhone(tempKey, phone, code)
-          取 providerUid + 验短信(复用 lodeing: redis code==input || "8888")
+          原子消费 tempKey + 取 providerUid + 验短信(复用 lodeing: redis code==input || "8888")
           getuser(phone):
             ├─ 已注册 → 关联(写 oauth)
             └─ 未注册 → createUser(phone) 新建 → 写 oauth
@@ -24,6 +24,7 @@ provider 凭证 ──▶ OAuthVerifyService.verify(provider, credential) ──
 - **token 签发**:复用 `JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, loginDto)`,仅给 `LoginUserDto` 加 `provider` 字段并在 setToken 写入 claim。
 - **新建用户**:复用 `createUser` 的「建 InfoUser + 建钱包 + 删旧 token」逻辑;昵称=手机号不变。
 - **短信**:复用 `getcode` 发送 + `lodeing` 的校验(key=phone 去+,万能码 8888)。
+- **一次性绑定凭证**:`tempKey` 在绑定请求开始时以删除成功作为唯一消费权;任一后续校验失败都必须重新发起 OAuth 登录,避免重放和并发重复绑定。
 
 ## 数据模型
 
@@ -31,7 +32,7 @@ provider 凭证 ──▶ OAuthVerifyService.verify(provider, credential) ──
 CREATE TABLE info_user_oauth (
   id           BIGINT AUTO_INCREMENT PRIMARY KEY,
   user_id      BIGINT       NOT NULL COMMENT '关联 info_user.user_id',
-  provider     VARCHAR(16)  NOT NULL COMMENT 'apple/google/line',
+  provider     VARCHAR(16)  NOT NULL COMMENT 'apple/google/line_user/line_rider/line_merchant',
   provider_uid VARCHAR(64)  NOT NULL COMMENT '三方稳定用户ID(Apple sub / Google sub / LINE userId)',
   create_time  DATETIME     DEFAULT CURRENT_TIMESTAMP,
   UNIQUE KEY uk_provider_uid (provider, provider_uid),
@@ -44,8 +45,8 @@ CREATE TABLE info_user_oauth (
 ### 1) `POST /infouser/user/oauthLogin`  (`@Anonymous`)
 请求 `OAuthLoginDto`:
 ```
-provider     apple|google|line
-credential   identityToken(Apple) / idToken(Google) / accessToken(LINE)
+provider     apple|google|line_user|line_rider|line_merchant
+credential   identityToken(Apple) / idToken(Google) / authorizationCode(LINE)
 cid, cidType, deviceToken, voIPToken   // 推送字段,与现有登录一致
 ```
 响应(命中):
@@ -71,7 +72,7 @@ cid, cidType, deviceToken, voIPToken
 |---|---|---|---|---|
 | apple | identityToken(ES256 JWT) | 拉 `appleid.apple.com/auth/keys`(JWKS) 验签 + 校 iss/aud/exp | `sub` | nimbus-jose-jwt(新增) |
 | google | ID-Token | GET `oauth2.googleapis.com/tokeninfo?id_token=` 取 claims + 校 aud=clientId | `sub` | httpclient4(已有) |
-| line | access_token | GET `api.line.me/v2/profile`(Bearer) | `userId` | httpclient4(已有) |
+| line_user / line_rider / line_merchant | authorization code | 按 provider 选择 Channel,POST 换 access_token,再 GET `api.line.me/v2/profile`(Bearer) | `userId` | httpclient4(已有) |
 
 > Google 走 tokeninfo HTTP 而非 firebase-admin,因后者需初始化 FirebaseApp+服务账号(项目未配置),tokeninfo 零配置即可。
 
@@ -93,3 +94,37 @@ cid, cidType, deviceToken, voIPToken
 
 - Apple / Google / LINE 的凭证校验**需用各家真实 token + 正确 clientId/bundleId 联调**才能端到端验证;配置值(clientId/jwks url)由前端/运营提供后填入 application.yml。
 - Apple JWKS 建议加缓存(key 偶尔轮换);初版每次拉取或加内存缓存。
+
+## 2026-09-04 增量:用户/骑手/商家 LINE 登录
+
+### 最小改造方案
+
+- 保留前端自行构造 LINE 授权 URL 的现有方式,不新增后端授权入口。
+- 共用 `GET /auth/line/callback`,回调 URL 增加 `provider=line_user|line_rider|line_merchant`。
+- `OAuthVerifyService` 按 provider 选择对应 Channel ID、Channel Secret 和 redirect URI;token/profile URL 继续共用。
+- `info_user_oauth.provider` 直接保存三个 provider 值;现有 `line` 数据通过 SQL 迁移为 `line_user`。
+- `line_user` 使用 `phone` 并保留首次自动创建;`line_rider`、`line_merchant` 使用 `tel_phone` 且只绑定已有账号。
+- 登录成功后分别使用 `USER_TOKEN_KEY`、`QS_TOKEN_KEY`、`SH_APP_TOKEN_KEY`。
+- 商家子账号除启用状态外,还必须能解析到有效归属商家/门店权限,失效归属不得通过 LINE 登录。
+- 骑手、商家及商家子账号统一使用 `tel_phone`。新增/修改入口在业务层检查有效业务账号手机号唯一,软删除账号可复用;不增加数据库唯一索引。
+
+### 受影响文件
+
+- `ruoyi-admin/src/main/resources/application.yml`:三组 LINE Channel 与 App 回跳配置。
+- `ruoyi-admin/.../utils/oauth/OAuthVerifyService.java`:按 provider 选择 LINE Channel。
+- `ruoyi-admin/.../user/LineCallbackController.java`:共用回调按 provider 分流。
+- `ruoyi-admin/.../user/InfoUserController.java`:分角色绑定、签发 token、手机号唯一校验。
+- `ruoyi-admin/.../stall/StallController.java`:摊主新增统一写入 `tel_phone` 并检查业务手机号唯一。
+- `ruoyi-admin/.../user/BusinessPhoneService.java`:有效业务账号手机号唯一检查。
+- `ruoyi-admin/.../user/MerchantSubaccountApplicationService.java`:子账号改用 `tel_phone`。
+- `updatesql/sql.md`:冲突预检后迁移 `line` provider 和历史业务账号手机号字段。
+- `specs/017-oauth-login/*`:同步接口及前端参数说明。
+
+三组 Channel Secret 不写入仓库,部署环境分别提供 `LINE_USER_CLIENT_SECRET`、`LINE_RIDER_CLIENT_SECRET`、`LINE_MERCHANT_CLIENT_SECRET`;缺失时 LINE 登录配置校验直接拒绝请求。
+
+### 验证
+
+- 单元测试覆盖 provider 配置选择、角色与 token key 映射、手机号重复判断。
+- JDK 21 定向测试与 `ruoyi-admin` 模块编译。
+- 真实 LINE Channel code、回调地址和三个 App scheme 仍需客户端联调验证。
+- `state` 的完整 CSRF 防护需服务端签发/消费并由三个 App 保存、回跳比对,作为三端协议联调项统一实施,不能只改单侧回调。

+ 10 - 4
specs/017-oauth-login/spec.md

@@ -24,19 +24,25 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 
 ## Functional Requirements
 
-- **FR-001**: 提供 `POST /infouser/user/oauthLogin {provider, credential, ...}`,后端校验 provider 凭证换取稳定 providerUid。
+- **FR-001**: 提供 `POST /infouser/user/oauthLogin {provider, credential, ...}`,后端校验 provider 凭证换取稳定 providerUid。provider 支持 `apple`、`google`、`line_user`、`line_rider`、`line_merchant`。
 - **FR-002**: 凭证校验三选一:Apple=验 ES256 identityToken 取 sub;Google=tokeninfo HTTP 验真取 sub 并校 audience;LINE=前端传授权 code,后端用 code+clientSecret+clientId 向 `oauth2/v2.1/token` 换 access_token,再调 v2/profile 取 userId(Authorization Code 流程,不直接收前端 accessToken)。
 - **FR-003**: 按 (provider, providerUid) 查 `info_user_oauth`:命中→校验用户 status/del_flag 正常后直接签发 token(claim `provider`)返回;未命中→缓存 {provider,providerUid} 到 Redis(短TTL),返回 `needPhone` + tempKey。
 - **FR-004**: 提供 `POST /infouser/user/oauthBindPhone {tempKey, phone, code, ...}`:取回缓存的 providerUid + 验短信码(复用 lodeing 逻辑,含万能码 8888)→ `getuser(phone)`:已注册→关联;未注册→`createUser` 新建;随后 insert `info_user_oauth(user_id, provider, provider_uid)`。
 - **FR-005**: 新建用户昵称统一用手机号(与现有 createUser 一致),avatar 留空,不用 provider 的昵称/头像。
-- **FR-006**: token claim 增加 `provider` 字段(apple/google/line;手机号登录为 phone),供登录渠道统计或「未绑手机限制」类约束使用。
+- **FR-006**: token claim 增加 `provider` 字段(apple/google/line_user/line_rider/line_merchant;手机号登录为 phone),供登录渠道统计或「未绑手机限制」类约束使用。
 - **FR-007**: 登录端点 `@Anonymous` 放行;受保护接口继续走 `@Auth`,零额外接入。
 - **FR-008**: 凭证校验失败 / tempKey 过期 / 短信码错误 → 明确错误提示,不签发 token、不建账号。
-- **FR-009**(2026-08-05 增量): LINE「唤起 LINE App / 系统浏览器」流程下前端拿不到 code,新增服务端回调 `GET /auth/line/callback`(@Anonymous,LineCallbackController):接 LINE 重定向的 code → 复用 verify("line",code) 换 token 取 userId → 已绑定签 token / 未绑定生成 tempKey → 302 跳回 App scheme `oauth.line.app-redirect`(`?token=` / `?needPhone=1&tempKey=` / `?error=`,未绑定时 App 再调 /infouser/user/oauthBindPhone)。原 /oauthLogin 保留,覆盖前端自取 code 场景(H5/webview/SDK)。约束:oauth.line.redirect-uri 须与 LINE Console 回调白名单一致(否则 400 redirect_uri_mismatch)。
+- **FR-009**(2026-08-05 增量,2026-09-04 扩展): LINE「唤起 LINE App / 系统浏览器」流程下前端拿不到 code,使用服务端回调 `GET /auth/line/callback`(@Anonymous,LineCallbackController):接收 provider 与 LINE 重定向的 code → 复用 `verify(provider, code)` 换 token 取 userId → 已绑定签 token / 未绑定生成 tempKey → 302 跳回 provider 对应 App scheme(`?token=` / `?needPhone=1&tempKey=` / `?error=`,未绑定时 App 再调 `/infouser/user/oauthBindPhone`)。原 `/oauthLogin` 保留,覆盖前端自取 code 场景(H5/webview/SDK)。
+- **FR-010**(2026-09-04 增量): LINE 登录按客户端区分 `line_user`、`line_rider`、`line_merchant`。三个客户端继续自行构造 LINE 授权地址,共用 `GET /auth/line/callback`,通过回调 URL 的 `provider` 查询参数选择对应 Channel 配置;`provider` 只能取上述三个白名单值。
+- **FR-011**: 三个 LINE Channel 使用各自的 Channel ID、Channel Secret、redirect URI 和 App 回跳地址。换取 access token 时提交的 redirect URI MUST 与发起授权时完全一致,包括 `provider` 查询参数。
+- **FR-012**: `line_user` 沿用普通用户首次登录的手机号关联/新建逻辑;`line_rider` 只允许绑定已有且有效的 `userType=2` 账号;`line_merchant` 只允许绑定已有且有效的 `userType=1/3/4/5` 账号,不自动创建骑手或商家。
+- **FR-013**: 普通用户手机号继续使用 `info_user.phone`;骑手和全部商家账号(`userType=1/2/3/4/5`)统一使用 `info_user.tel_phone`。有效商家/骑手的 `tel_phone` 不得重复,软删除账号(`del_flag!='0'`)不占用手机号;手机号按存储字符串精确比较,`0912...` 与 `+886...` 视为不同号码。普通用户的 `phone` 可与商家/骑手的 `tel_phone` 相同。
+- **FR-014**: 商家/骑手手机号唯一性通过业务层校验实现,不新增数据库唯一索引。新增与修改时均校验,修改时排除当前 `user_id`。
+- **FR-015**: LINE 登录成功后,`line_user`、`line_rider`、`line_merchant` 分别签发普通用户、骑手、商家 App 会话 token,并跳回对应 App scheme。
 
 ## Key Entities
 
-- **info_user_oauth(新增)**:`id`、`user_id`、`provider`(apple/google/line)、`provider_uid`、`create_time`。`UNIQUE(provider, provider_uid)` 用于按三方ID反查用户;`user_id` 普通索引。
+- **info_user_oauth(新增)**:`id`、`user_id`、`provider`(apple/google/line_user/line_rider/line_merchant)、`provider_uid`、`create_time`。`UNIQUE(provider, provider_uid)` 用于按三方ID反查用户;`user_id` 普通索引。
 - **InfoUser(已有,不改)**:仍是手机号为主键,无三方 ID 列。
 
 ## 安全要点

+ 35 - 16
specs/017-oauth-login/tasks.md

@@ -2,23 +2,25 @@
 
 > 顺序执行;每步完成后编译验证。
 
-- [ ] T1 数据模型:`updatesql/sql.md` 加 `info_user_oauth` 建表(含 uk_provider_uid 唯一索引、idx_user_id)。
-- [ ] T2 实体:`InfoUserOauth.java`(@TableName=info_user_oauth,字段 id/userId/provider/providerUid/createTime)。
-- [ ] T3 mapper:`InfoUserOauthMapper extends BaseMapper<InfoUserOauth>`(无 XML)。
-- [ ] T4 LoginUserDto 加 `provider` 字段。
-- [ ] T5 JwtUtil.setToken(tokenKey, LoginUserDto) 加 `provider` claim(非空才写)。
-- [ ] T6 配置:application.yml 加 `oauth.apple.clientId/jwks-url`、`oauth.google.clientId/tokeninfo-url`、`oauth.line.profile-url`。
-- [ ] T7 依赖:ruoyi-admin/pom.xml 加 `nimbus-jose-jwt`。
-- [ ] T8 OAuthVerifyService:`verify(provider, credential)` → providerUid;Apple(nimbus 验 ES256)、Google(tokeninfo)、LINE(v2/profile),校 audience/iss/exp。
-- [ ] T9 DTO:OAuthLoginDto、OAuthBindDto。
-- [ ] T10 InfoUserController.oauthLogin:校验→查 oauth→命中签 token / 未命中缓存 tempKey 返 needPhone。
-- [ ] T11 InfoUserController.oauthBindPhone:取 providerUid + 验短信→关联/新建→写 oauth→签 token。
-- [ ] T12 编译验证(JDK21):ruoyi-admin + ruoyi-system + ruoyi-common。
+- [x] T1 数据模型:`updatesql/sql.md` 加 `info_user_oauth` 建表(含 uk_provider_uid 唯一索引、idx_user_id)。
+- [x] T2 实体:`InfoUserOauth.java`(@TableName=info_user_oauth,字段 id/userId/provider/providerUid/createTime)。
+- [x] T3 mapper:`InfoUserOauthMapper extends BaseMapper<InfoUserOauth>`(无 XML)。
+- [x] T4 LoginUserDto 加 `provider` 字段。
+- [x] T5 JwtUtil.setToken(tokenKey, LoginUserDto) 加 `provider` claim(非空才写)。
+- [x] T6 配置:application.yml 加 `oauth.apple.clientId/jwks-url`、`oauth.google.clientId/tokeninfo-url`、`oauth.line.profile-url`。
+- [x] T7 依赖:ruoyi-admin/pom.xml 加 `nimbus-jose-jwt`。
+- [x] T8 OAuthVerifyService:`verify(provider, credential)` → providerUid;Apple(nimbus 验 ES256)、Google(tokeninfo)、LINE(v2/profile),校 audience/iss/exp。
+- [x] T9 DTO:OAuthLoginDto、OAuthBindDto。
+- [x] T10 InfoUserController.oauthLogin:校验→查 oauth→命中签 token / 未命中缓存 tempKey 返 needPhone。
+- [x] T11 InfoUserController.oauthBindPhone:取 providerUid + 验短信→关联/新建→写 oauth→签 token。
+- [x] T12 编译验证(JDK21):ruoyi-admin + ruoyi-system + ruoyi-common。
 
 ## 联调待办(开发提供配置后)
-- [ ] application.yml 填真实 apple bundleId/clientId、google clientId、line channel。
-- [ ] 三家真实 token 端到端验证(Apple 需真机/测试机签 Sign in with Apple)。
-- [ ] 前端 app:登录页三按钮 + 首次绑手机流程 + i18n。
+- [ ] 部署环境提供真实 Apple bundleId/clientId、Google clientId,以及 `LINE_USER_CLIENT_SECRET`、`LINE_RIDER_CLIENT_SECRET`、`LINE_MERCHANT_CLIENT_SECRET`;Secret 不写入仓库。
+- [ ] 部署前轮换 Git 历史中曾出现的旧 LINE Channel Secret。
+- [ ] 使用真实 Apple、Google、三套 LINE Channel token/code 完成端到端验证(Apple 需真机/测试机 Sign in with Apple)。
+- [ ] 三个前端 App 完成登录入口、首次绑手机、provider/回跳 scheme 分流及 i18n 联调。
+- [ ] 三端统一升级 `state` CSRF 协议:服务端签发并单次消费,App 保存发起值并在回跳时比对。
 
 ## 代码评审修复 (2026-07-30)
 
@@ -29,4 +31,21 @@
 - [x] **Apple audience**:原 `aud.get(0)` 在多 audience 时可能漏判 → 改 `aud.contains(appleClientId)`。
 - [x] **i18n 化(裸中文 → message key)**:新增 `no.oauth.*` 共 10 个 key,写入 5 份 properties(`messages` / `zh_CN` / `zh_TW` / `en_US` / `vi`);`OAuthVerifyService` 异常文案与 `InfoUserController` 提示全部走 `MessageUtils.message`,与 `lodeing` 风格一致。新增 key:
   - 控制器:`no.oauth.provider.blank` / `needphone` / `tempkey.missing` / `tempkey.expired`
-  - 校验服务:`no.oauth.credential.blank` / `provider.unsupported` / `token.invalid` / `token.expired` / `audience.mismatch` / `verify.fail`(带 `{0}` 渠道名、`verify.fail` 带 `{1}` 异常详情)
+  - 校验服务:`no.oauth.credential.blank` / `provider.unsupported` / `token.invalid` / `token.expired` / `audience.mismatch` / `verify.fail`(带 `{0}` 渠道名;异常详情仅记录在服务端日志,不返回客户端)
+
+## 用户/骑手/商家 LINE 登录增量(2026-09-04)
+
+- [x] T13 配置三组 LINE Channel:`line_user`、`line_rider`、`line_merchant`,共用 token/profile URL,分别配置 redirect URI 和 App scheme。
+- [x] T14 `OAuthVerifyService` 按 provider 白名单选择对应 Channel ID、Secret、redirect URI,并使用完全相同的 redirect URI 换 token。
+- [x] T15 共用 `GET /auth/line/callback`,接收 provider/code/state,按 provider 分流验证、绑定查询、token key 和 App 回跳。
+- [x] T16 `oauthLogin` / `oauthBindPhone` 支持三个 LINE provider;骑手只绑定 `userType=2`,商家只绑定 `userType=1/3/4/5`,均不得自动创建。
+- [x] T17 商家/骑手/子账号统一使用 `tel_phone`;业务层增加有效账号手机号唯一检查,修改时排除自身,软删除后允许复用。
+- [x] T18 覆盖 `addqishou`、`addshanghu`、`Bindingphone`、`setuser`、平台新增/修改、`stall/addOwner` 及商家子账号新增入口。
+- [x] T19 SQL:先预检 provider 和有效业务账号手机号冲突,再将现有 `info_user_oauth.provider='line'` 迁移为 `line_user`,并把业务账号 `phone` 补入 `tel_phone`,不清除原字段。
+- [x] T20 更新 LINE 前端接入说明:原授权方式不变,三个端分别增加 provider 参数并使用对应 Channel ID/回跳 scheme。
+- [x] T21 编写并运行定向测试,覆盖 provider 分流、角色限制、手机号唯一规则(2026-09-04:31 tests,0 failures/errors)。
+- [x] T22 使用 JDK 21 编译 `ruoyi-common`、`ruoyi-system`、`ruoyi-admin`(2026-09-04 reactor package SUCCESS);真实 LINE 端到端联调保留为外部待办。
+
+> 2026-09-04 另执行完整 reactor test:`ruoyi-system` 53 tests 全通过,`ruoyi-admin` 371 tests 中 370 通过、1 error。失败项为既有 `PosOrderQsOprateControllerTest.newTasksContainOnlyPaidOrdersWaitingForRiderBeforeMerchantAcceptance` 未注入 `UserService` 导致 NPE,与 017 修改文件及定向测试无关,本次不跨范围修改。
+
+> 交付记录(2026-09-04):最终代码复核未发现剩余 Critical/Important;`git diff --check` 通过;数据库迁移未执行,须先运行 `updatesql/sql.md` 中两项冲突预检再由开发者手工执行;017 修改当前保留在工作区,尚未提交。

+ 36 - 1
updatesql/sql.md

@@ -455,7 +455,7 @@ ALTER TABLE info_invoice
 CREATE TABLE info_user_oauth (
   id           BIGINT AUTO_INCREMENT PRIMARY KEY,
   user_id      BIGINT       NOT NULL COMMENT '关联 info_user.user_id',
-  provider     VARCHAR(16)  NOT NULL COMMENT '三方渠道:apple/google/line',
+  provider     VARCHAR(16)  NOT NULL COMMENT '三方渠道:apple/google/line_user/line_rider/line_merchant',
   provider_uid VARCHAR(64)  NOT NULL COMMENT '三方稳定用户ID(Apple sub / Google sub / LINE userId)',
   create_time  DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
   UNIQUE KEY uk_provider_uid (provider, provider_uid),
@@ -463,6 +463,41 @@ CREATE TABLE info_user_oauth (
 ) COMMENT='三方账号绑定(Apple/Google/LINE)';
 ```
 
+## 2026-09-04 LINE 三端登录数据迁移(017-oauth-login)
+
+> 用途:原普通用户 LINE 绑定迁移为 `line_user`;历史业务账号手机号补入统一的 `tel_phone` 字段。只记录脚本,由开发者手动执行;不清除原 `phone` 字段。先执行两项冲突预检,任一查询返回记录时必须人工消除冲突后再执行更新。
+
+```sql
+-- 预检 1:旧 line 绑定不能与已有 line_user 绑定形成唯一键冲突;必须返回 0 行
+SELECT legacy.id AS legacy_id, current.id AS current_id, legacy.provider_uid
+FROM info_user_oauth legacy
+JOIN info_user_oauth current
+  ON current.provider = 'line_user'
+ AND current.provider_uid = legacy.provider_uid
+WHERE legacy.provider = 'line';
+
+-- 预检 2:有效业务账号迁移后的 tel_phone 必须唯一;必须返回 0 行
+SELECT COALESCE(NULLIF(tel_phone, ''), NULLIF(phone, '')) AS planned_tel_phone,
+       COUNT(*) AS account_count
+FROM info_user
+WHERE user_type IN ('1', '2', '3', '4', '5')
+  AND del_flag = '0'
+  AND COALESCE(NULLIF(tel_phone, ''), NULLIF(phone, '')) IS NOT NULL
+GROUP BY COALESCE(NULLIF(tel_phone, ''), NULLIF(phone, ''))
+HAVING COUNT(*) > 1;
+
+UPDATE info_user_oauth
+SET provider = 'line_user'
+WHERE provider = 'line';
+
+UPDATE info_user
+SET tel_phone = phone
+WHERE user_type IN ('1', '2', '3', '4', '5')
+  AND (tel_phone IS NULL OR tel_phone = '')
+  AND phone IS NOT NULL
+  AND phone <> '';
+```
+
 ## 2026-08-03 骑手 newTask 距离上限字典
 
 ```sql

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác