소스 검색

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

完善 OAuth 登录、手机号绑定、商家账号及摊位访问逻辑。
补充优惠券优惠上限自动化测试和相关规格记录。
一并提交当前工作区中的 SQL、国际航班规格及蓝湖分析验证资料。
qmj 2 시간 전
부모
커밋
af8cc41563
45개의 변경된 파일2060개의 추가작업 그리고 221개의 파일을 삭제
  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"}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 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

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.