Jelajahi Sumber

补齐骑手商家第三方登录并兼容用户端旧协议

新增骑手、商家苹果和谷歌独立渠道,按业务角色绑定已有账号并签发对应会话。完善业务端 LINE 一次性授权状态和真实短信验证,兼容用户旧 LINE 请求、回调及绑定数据。

同步配置、国际化、接入说明与验证记录。79 项定向测试及 JDK 21 打包通过;完整回归仍存在既有骑手订单测试注入错误,业务 App 接入与真实渠道联调待完成。
qmj 1 jam dari sekarang
induk
melakukan
cab1307f90
25 mengubah file dengan 907 tambahan dan 88 penghapusan
  1. 31 31
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  2. 35 13
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  3. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/LineAuthorizationDto.java
  4. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java
  5. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/LineOAuthProperties.java
  6. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/LineOAuthStateService.java
  7. 57 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthAccountType.java
  8. 45 11
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java
  9. 7 0
      ruoyi-admin/src/main/resources/application.yml
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 182 24
      ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java
  16. 50 3
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java
  17. 14 2
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/LineOAuthPropertiesTest.java
  18. 58 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/LineOAuthStateServiceTest.java
  19. 178 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/OAuthVerifyServiceTest.java
  20. 7 0
      specs/017-oauth-login/line-callback-frontend.md
  21. 81 0
      specs/017-oauth-login/oauth-app-frontend.md
  22. 11 1
      specs/017-oauth-login/plan.md
  23. 12 2
      specs/017-oauth-login/spec.md
  24. 17 0
      specs/017-oauth-login/tasks.md
  25. 17 0
      updatesql/sql.md

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

@@ -46,7 +46,8 @@ 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.OAuthAccountType;
+import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
@@ -109,7 +110,7 @@ public class InfoUserController extends BaseController {
     @Autowired
     private OAuthVerifyService oauthVerifyService;
     @Autowired
-    private LineOAuthProperties lineOAuthProperties;
+    private LineOAuthStateService lineOAuthStateService;
     @Autowired
     private BusinessPhoneService businessPhoneService;
     @Autowired
@@ -1044,14 +1045,17 @@ 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());
+        OAuthAccountType accountType = OAuthAccountType.forProvider(dto.getProvider());
+        if (LineOAuthStateService.isBusinessLine(dto.getProvider())) {
+            lineOAuthStateService.consume(dto.getProvider(), dto.getState());
+        }
         log.info("[OAuth] oauthLogin provider={}", dto.getProvider());
         String providerUid = oauthVerifyService.verify(dto.getProvider(), dto.getCredential());
 
         // 查是否已绑定
         InfoUserOauth bind = infoUserOauthMapper.selectOne(
                 new LambdaQueryWrapper<InfoUserOauth>()
-                        .eq(InfoUserOauth::getProvider, dto.getProvider())
+                        .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(dto.getProvider()))
                         .eq(InfoUserOauth::getProviderUid, providerUid));
         if (bind != null) {
             // 已绑定:校验用户正常后直接登录
@@ -1063,7 +1067,7 @@ public class InfoUserController extends BaseController {
                 log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
                 return error(MessageUtils.message("no.user.stop"));
             }
-            if (lineChannel != null && !canLineLogin(lineChannel, u)) {
+            if (accountType != null && !canOauthLogin(accountType, u)) {
                 return error(MessageUtils.message("no.user.stop"));
             }
             u.setCid(dto.getCid());
@@ -1108,46 +1112,48 @@ public class InfoUserController extends BaseController {
         }
         String provider = cached.substring(0, at);
         String providerUid = cached.substring(at + 1);
-        LineOAuthProperties.ResolvedChannel lineChannel = resolveLineChannel(provider);
+        OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
         log.info("[OAuth] 绑定流程 provider={}", provider);
 
         // 容错:若期间已被绑定,直接登录
         InfoUserOauth exist = infoUserOauthMapper.selectOne(
                 new LambdaQueryWrapper<InfoUserOauth>()
-                        .eq(InfoUserOauth::getProvider, provider)
+                        .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
                         .eq(InfoUserOauth::getProviderUid, providerUid));
         if (exist != null) {
             log.info("[OAuth] 绑定期间已被绑定,直接登录 userId={}", exist.getUserId());
             InfoUser u = infoUserService.getById(exist.getUserId());
-            if (lineChannel != null && !canLineLogin(lineChannel, u)) {
+            if (accountType != null && !canOauthLogin(accountType, 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());
+        boolean businessAccount = accountType != null && !accountType.allowCreate();
+        boolean codeOk = (xcode != null && xcode.equals(dto.getCode()))
+                || (!businessAccount && "8888".equals(dto.getCode()));
         if (!codeOk) {
             log.warn("[OAuth] 短信验证码错误 phone={}", maskPhone(phone));
             return error(MessageUtils.message("no.user.jcaptcha.error"));
         }
 
         InfoUser user;
-        if (lineChannel != null) {
-            user = findLineBindingUser(lineChannel, phone);
+        if (accountType != null) {
+            user = findOauthBindingUser(accountType, phone);
         } else {
             user = infoUserService.getuser(phone);
         }
-        if (lineChannel != null && !lineChannel.allowCreate()) {
+        if (accountType != null && !accountType.allowCreate()) {
             if (user == null) {
                 return error(MessageUtils.message("no.user.not.exist"));
             }
-            if (!canLineLogin(lineChannel, user)) {
+            if (!canOauthLogin(accountType, user)) {
                 return error(MessageUtils.message("no.user.stop"));
             }
         }
@@ -1156,7 +1162,7 @@ public class InfoUserController extends BaseController {
             log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
             return error(MessageUtils.message("no.user.stop"));
         }
-        if (user == null && (lineChannel == null || lineChannel.allowCreate())) {
+        if (user == null && (accountType == null || accountType.allowCreate())) {
             // 新建:昵称=手机号(与 createUser 一致)
             InfoUser info = new InfoUser();
             info.setPhone(phone);
@@ -1170,8 +1176,8 @@ public class InfoUserController extends BaseController {
             if (!infoUserService.saveOrUpdate(info)) {
                 throw new ServiceException(MessageUtils.message("no.system.error"));
             }
-            user = lineChannel == null
-                    ? infoUserService.getuser(phone) : findLineBindingUser(lineChannel, phone);
+            user = accountType == null
+                    ? infoUserService.getuser(phone) : findOauthBindingUser(accountType, phone);
             if (user == null) {
                 return error(MessageUtils.message("no.user.not.exist"));
             }
@@ -1191,7 +1197,7 @@ public class InfoUserController extends BaseController {
         // 写三方绑定
         InfoUserOauth bind = new InfoUserOauth();
         bind.setUserId(user.getUserId());
-        bind.setProvider(provider);
+        bind.setProvider("line".equals(provider) ? "line_user" : provider);
         bind.setProviderUid(providerUid);
         bind.setCreateTime(new Date());
         if (infoUserOauthMapper.insert(bind) != 1) {
@@ -1218,30 +1224,24 @@ public class InfoUserController extends BaseController {
         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)) {
+        OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
+        if (accountType != null && !canOauthLogin(accountType, user)) {
             return error(MessageUtils.message("no.user.stop"));
         }
-        String tokenKey = lineChannel == null ? CacheConstants.USER_TOKEN_KEY : lineChannel.tokenKey();
+        String tokenKey = accountType == null ? CacheConstants.USER_TOKEN_KEY : accountType.tokenKey();
         redisCache.deleteKeys(tokenKey + user.getUserId() + ":*");
         LoginUserDto dto = new LoginUserDto();
         dto.setUserId(user.getUserId());
-        dto.setUserName(lineChannel == null || lineChannel.allowCreate()
+        dto.setUserName(accountType == null || accountType.allowCreate()
                 ? user.getPhone() : user.getUserName());
         dto.setProvider(provider);
         fillLoginUserInfo(dto);
         String token = JwtUtil.setToken(tokenKey, dto);
-        Object loginUser = lineChannel != null
-                && "line_merchant".equals(lineChannel.provider()) ? merchantLoginView(user) : user;
+        Object loginUser = accountType == OAuthAccountType.MERCHANT ? 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) {
+    private boolean canOauthLogin(OAuthAccountType channel, InfoUser user) {
         if (user == null || !"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag())
                 || !channel.userTypes().contains(user.getUserType())) {
             return false;
@@ -1260,7 +1260,7 @@ public class InfoUserController extends BaseController {
         }
     }
 
-    private InfoUser findLineBindingUser(LineOAuthProperties.ResolvedChannel channel, String phone) {
+    private InfoUser findOauthBindingUser(OAuthAccountType channel, String phone) {
         LambdaQueryWrapper<InfoUser> query = new LambdaQueryWrapper<InfoUser>()
                 .eq(channel.allowCreate(), InfoUser::getPhone, phone)
                 .eq(!channel.allowCreate(), InfoUser::getTelPhone, phone)

+ 35 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -3,6 +3,11 @@ package com.ruoyi.app.user;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.utils.oauth.LineOAuthProperties;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.app.utils.oauth.OAuthAccountType;
+import com.ruoyi.app.utils.oauth.LineOAuthStateService;
+import com.ruoyi.app.user.dto.LineAuthorizationDto;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.model.LoginUserDto;
@@ -24,6 +29,8 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
@@ -54,6 +61,17 @@ public class LineCallbackController extends BaseController {
     private RedisCache redisCache;
     @Autowired
     private MerchantStoreAccessService merchantStoreAccessService;
+    @Autowired
+    private LineOAuthStateService lineOAuthStateService;
+
+    @Anonymous
+    @PostMapping("/authorize")
+    public AjaxResult authorize(@RequestBody LineAuthorizationDto request) {
+        if (request == null || request.getProvider() == null || request.getProvider().isBlank()) {
+            return error(MessageUtils.message("no.oauth.provider.blank"));
+        }
+        return AjaxResult.success(lineOAuthStateService.authorize(request.getProvider()));
+    }
 
     @Anonymous
     @GetMapping("/callback")
@@ -61,6 +79,10 @@ public class LineCallbackController extends BaseController {
                          @RequestParam(value = "code", required = false) String code,
                          @RequestParam(value = "state", required = false) String state,
                          HttpServletResponse response) throws IOException {
+        // 旧用户 App 的已注册地址不带 provider,换 token 必须保留旧地址。
+        if (provider == null) {
+            provider = "line";
+        }
         LineOAuthProperties.ResolvedChannel channel;
         try {
             channel = lineOAuthProperties.requireChannel(provider);
@@ -69,16 +91,21 @@ public class LineCallbackController extends BaseController {
             return;
         }
 
+        boolean businessLine = LineOAuthStateService.isBusinessLine(provider);
+        String stateSuffix = businessLine ? "&state=" + enc(state) : "";
         try {
+            if (businessLine) {
+                lineOAuthStateService.consume(provider, state);
+            }
             if (code == null || code.isEmpty()) {
-                log.warn("[OAuth][LINE] callback 缺少 code, provider={}, state={}", provider, state);
-                redirectToApp(response, channel.appRedirect(), "error", "no_code");
+                log.warn("[OAuth][LINE] callback 缺少 code, provider={}", provider);
+                response.sendRedirect(channel.appRedirect() + "?error=no_code" + stateSuffix);
                 return;
             }
             String providerUid = oauthVerifyService.verify(provider, code);
             InfoUserOauth bind = infoUserOauthMapper.selectOne(
                     new LambdaQueryWrapper<InfoUserOauth>()
-                            .eq(InfoUserOauth::getProvider, provider)
+                            .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
                             .eq(InfoUserOauth::getProviderUid, providerUid));
             if (bind != null) {
                 InfoUser user = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
@@ -86,21 +113,21 @@ public class LineCallbackController extends BaseController {
                         .eq(InfoUser::getStatus, "0")
                         .eq(InfoUser::getDelFlag, "0"));
                 if (!canLogin(channel, user)) {
-                    redirectToApp(response, channel.appRedirect(), "error", "user_stopped");
+                    response.sendRedirect(channel.appRedirect() + "?error=user_stopped" + stateSuffix);
                     return;
                 }
                 String token = buildOauthToken(user, channel);
-                response.sendRedirect(channel.appRedirect() + "?token=" + enc(token));
+                response.sendRedirect(channel.appRedirect() + "?token=" + enc(token) + stateSuffix);
                 return;
             }
 
             String tempKey = UUID.randomUUID().toString().replace("-", "");
             redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
                     provider + "@" + providerUid, 5, TimeUnit.MINUTES);
-            response.sendRedirect(channel.appRedirect() + "?needPhone=1&tempKey=" + enc(tempKey));
+            response.sendRedirect(channel.appRedirect() + "?needPhone=1&tempKey=" + enc(tempKey) + stateSuffix);
         } catch (Exception exception) {
-            log.error("[OAuth][LINE] callback 异常 provider={}", provider, exception);
-            redirectToApp(response, channel.appRedirect(), "error", "fail");
+            log.error("[OAuth][LINE] callback 异常 provider={}, type={}", provider, exception.getClass().getSimpleName());
+            response.sendRedirect(channel.appRedirect() + "?error=fail" + stateSuffix);
         }
     }
 
@@ -142,11 +169,6 @@ public class LineCallbackController extends BaseController {
         userDto.setLoginTime(System.currentTimeMillis());
     }
 
-    private void redirectToApp(HttpServletResponse response, String appRedirect,
-                               String key, String value) throws IOException {
-        response.sendRedirect(appRedirect + "?" + key + "=" + enc(value));
-    }
-
     private static String enc(String value) {
         return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
     }

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/LineAuthorizationDto.java

@@ -0,0 +1,9 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/** 骑手、商家 App 发起 LINE 登录。 */
+@Data
+public class LineAuthorizationDto {
+    private String provider;
+}

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

@@ -11,12 +11,15 @@ import lombok.Data;
 @Data
 public class OAuthLoginDto {
 
-    /** 三方渠道 apple/google/line_user/line_rider/line_merchant */
+    /** 用户 apple/google/line/line_user;业务端 apple/google/line 加 _rider 或 _merchant。 */
     private String provider;
 
     /** 凭证:Apple identityToken / Google idToken / LINE authorization code */
     private String credential;
 
+    /** 业务 App LINE 授权时服务端签发的 state;其他渠道及旧用户端不要求。 */
+    private String state;
+
     /** 推送字段(与现有登录一致) */
     private String cid;
     private String cidType;

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

@@ -17,8 +17,16 @@ public class LineOAuthProperties {
     private Channel user;
     private Channel rider;
     private Channel merchant;
+    private String legacyRedirectUri;
 
     public ResolvedChannel requireChannel(String provider) {
+        if ("line".equals(provider)) {
+            ResolvedChannel current = resolved(provider, user, CacheConstants.USER_TOKEN_KEY, Set.of("0"), true);
+            String redirect = isBlank(legacyRedirectUri)
+                    ? current.redirectUri().split("\\?", 2)[0] : legacyRedirectUri;
+            return new ResolvedChannel(provider, current.clientId(), current.clientSecret(),
+                    redirect, current.appRedirect(), current.tokenKey(), current.userTypes(), true);
+        }
         if ("line_user".equals(provider)) {
             return resolved(provider, user, CacheConstants.USER_TOKEN_KEY, Set.of("0"), true);
         }
@@ -50,6 +58,14 @@ public class LineOAuthProperties {
         return tokenUrl;
     }
 
+    public String getLegacyRedirectUri() {
+        return legacyRedirectUri;
+    }
+
+    public void setLegacyRedirectUri(String legacyRedirectUri) {
+        this.legacyRedirectUri = legacyRedirectUri;
+    }
+
     public void setTokenUrl(String tokenUrl) {
         this.tokenUrl = tokenUrl;
     }

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/LineOAuthStateService.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.springframework.stereotype.Service;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/** 业务 App 的 LINE 授权状态;用户旧协议保持兼容。 */
+@Service
+public class LineOAuthStateService {
+    private static final String PREFIX = "oauth:line:state:";
+    private final LineOAuthProperties properties;
+    private final RedisCache redisCache;
+
+    public LineOAuthStateService(LineOAuthProperties properties, RedisCache redisCache) {
+        this.properties = properties;
+        this.redisCache = redisCache;
+    }
+
+    public Authorization authorize(String provider) {
+        if (!isBusinessLine(provider)) {
+            throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
+        }
+        LineOAuthProperties.ResolvedChannel channel = properties.requireChannel(provider);
+        String state = UUID.randomUUID().toString().replace("-", "");
+        redisCache.setCacheObject(PREFIX + provider + ":" + state, provider, 5, TimeUnit.MINUTES);
+        String url = "https://access.line.me/oauth2/v2.1/authorize?response_type=code"
+                + "&client_id=" + enc(channel.clientId()) + "&redirect_uri=" + enc(channel.redirectUri())
+                + "&state=" + state + "&scope=profile%20openid";
+        return new Authorization(url, state);
+    }
+
+    public void consume(String provider, String state) {
+        if (!isBusinessLine(provider)) {
+            return;
+        }
+        if (state == null || !state.matches("[a-f0-9]{32}")) {
+            throw invalidState();
+        }
+        String key = PREFIX + provider + ":" + state;
+        Object cached = redisCache.getCacheObject(key);
+        // Redis DEL 的成功结果决定唯一消费权;不同端使用独立 key。
+        if (!provider.equals(cached) || !redisCache.deleteObject(key)) {
+            throw invalidState();
+        }
+    }
+
+    public static boolean isBusinessLine(String provider) {
+        return "line_rider".equals(provider) || "line_merchant".equals(provider);
+    }
+
+    private ServiceException invalidState() {
+        return new ServiceException(MessageUtils.message("no.oauth.state.invalid"));
+    }
+
+    private String enc(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    public record Authorization(String authorizationUrl, String state) { }
+}

+ 57 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthAccountType.java

@@ -0,0 +1,57 @@
+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 java.util.List;
+import java.util.Set;
+
+/** OAuth 登录端决定账号权限和会话,不能由客户端指定用户角色。 */
+public enum OAuthAccountType {
+    USER(CacheConstants.USER_TOKEN_KEY, Set.of("0"), true),
+    RIDER(CacheConstants.QS_TOKEN_KEY, Set.of("2"), false),
+    MERCHANT(CacheConstants.SH_APP_TOKEN_KEY, Set.of("1", "3", "4", "5"), false);
+
+    private final String tokenKey;
+    private final Set<String> userTypes;
+    private final boolean allowCreate;
+
+    OAuthAccountType(String tokenKey, Set<String> userTypes, boolean allowCreate) {
+        this.tokenKey = tokenKey;
+        this.userTypes = userTypes;
+        this.allowCreate = allowCreate;
+    }
+
+    public static OAuthAccountType forProvider(String provider) {
+        if (provider == null) {
+            throw new ServiceException(MessageUtils.message("no.oauth.provider.blank"));
+        }
+        return switch (provider) {
+            // 保持原有 Apple / Google 用户绑定查找逻辑。
+            case "apple", "google" -> null;
+            case "line", "line_user" -> USER;
+            case "apple_rider", "google_rider", "line_rider" -> RIDER;
+            case "apple_merchant", "google_merchant", "line_merchant" -> MERCHANT;
+            default -> throw new ServiceException(
+                    MessageUtils.message("no.oauth.provider.unsupported", provider));
+        };
+    }
+
+    public static List<String> bindingProviders(String provider) {
+        return "line".equals(provider) || "line_user".equals(provider)
+                ? List.of("line", "line_user") : List.of(provider);
+    }
+
+    public String tokenKey() {
+        return tokenKey;
+    }
+
+    public Set<String> userTypes() {
+        return userTypes;
+    }
+
+    public boolean allowCreate() {
+        return allowCreate;
+    }
+}

+ 45 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java

@@ -38,7 +38,7 @@ import java.util.List;
  * 三方登录凭证校验(017-oauth-login)。
  * <p>Apple/Google/LINE 各自校验前端传来的凭证,返回稳定的 providerUid(用于绑定反查)。</p>
  * <ul>
- *   <li>Apple:nimbus 验 ES256 identityToken + 校 iss/aud/exp → sub(忽略 email)</li>
+ *   <li>Apple:nimbus 按公钥类型验 identityToken + 校 iss/aud/exp → sub(忽略 email)</li>
  *   <li>Google:tokeninfo HTTP 验真 + 校 audience → sub</li>
  *   <li>LINE:code 换 access_token → v2/profile → userId</li>
  * </ul>
@@ -60,13 +60,21 @@ public class OAuthVerifyService {
     private String googleClientId;
     @Value("${oauth.google.tokeninfo-url}")
     private String googleTokeninfoUrl;
+    @Value("${oauth.apple.rider-client-id:}")
+    private String appleRiderClientId;
+    @Value("${oauth.apple.merchant-client-id:}")
+    private String appleMerchantClientId;
+    @Value("${oauth.google.rider-client-id:}")
+    private String googleRiderClientId;
+    @Value("${oauth.google.merchant-client-id:}")
+    private String googleMerchantClientId;
     @Autowired
     private LineOAuthProperties lineOAuthProperties;
 
     /**
      * 校验 provider 凭证,返回稳定的 providerUid。
      *
-     * @param provider   apple/google/line_user/line_rider/line_merchant
+     * @param provider   用户原 apple/google/line/line_user;业务端渠道带 _rider 或 _merchant
      * @param credential Apple identityToken / Google idToken / LINE authorization code
      */
     public String verify(String provider, String credential) {
@@ -79,9 +87,18 @@ public class OAuthVerifyService {
         log.info("[OAuth] 校验凭证 provider={}", provider);
         switch (provider) {
             case "apple":
-                return verifyApple(credential);
+                return verifyApple(credential, appleClientId);
+            case "apple_rider":
+                return verifyApple(credential, requireClientId(appleRiderClientId, "Apple"));
+            case "apple_merchant":
+                return verifyApple(credential, requireClientId(appleMerchantClientId, "Apple"));
             case "google":
-                return verifyGoogle(credential);
+                return verifyGoogle(credential, googleClientId, false);
+            case "google_rider":
+                return verifyGoogle(credential, requireClientId(googleRiderClientId, "Google"), true);
+            case "google_merchant":
+                return verifyGoogle(credential, requireClientId(googleMerchantClientId, "Google"), true);
+            case "line":
             case "line_user":
             case "line_rider":
             case "line_merchant":
@@ -91,8 +108,8 @@ public class OAuthVerifyService {
         }
     }
 
-    /** Apple:验 ES256 identityToken,返回 sub(不使用邮箱信息)。 */
-    private String verifyApple(String idToken) {
+    /** Apple:验证 identityToken 签名,返回 sub(不使用邮箱信息)。 */
+    private String verifyApple(String idToken, String clientId) {
         try {
             SignedJWT jwt = SignedJWT.parse(idToken);
             String kid = jwt.getHeader().getKeyID();
@@ -123,8 +140,8 @@ public class OAuthVerifyService {
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
             }
             List<String> aud = claims.getAudience();
-            if (aud == null || !aud.contains(appleClientId)) {
-                log.warn("[OAuth][Apple] aud 不匹配: expected={}, got={}", appleClientId, aud);
+            if (aud == null || !aud.contains(clientId)) {
+                log.warn("[OAuth][Apple] aud 不匹配");
                 throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Apple"));
             }
             Date exp = claims.getExpirationTime();
@@ -148,7 +165,7 @@ public class OAuthVerifyService {
     }
 
     /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
-    private String verifyGoogle(String idToken) {
+    private String verifyGoogle(String idToken, String clientId, boolean businessClient) {
         try {
             String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
             JSONObject json = JSONObject.parseObject(httpGet(url, null));
@@ -157,10 +174,20 @@ public class OAuthVerifyService {
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
             }
             String aud = json.getString("aud");
-            if (!googleClientId.equals(aud)) {
-                log.warn("[OAuth][Google] aud 不匹配: expected={}, got={}", googleClientId, aud);
+            if (!clientId.equals(aud)) {
+                log.warn("[OAuth][Google] aud 不匹配");
                 throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Google"));
             }
+            if (businessClient) {
+                String issuer = json.getString("iss");
+                if (!"https://accounts.google.com".equals(issuer) && !"accounts.google.com".equals(issuer)) {
+                    throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
+                }
+                Long expiresAt = json.getLong("exp");
+                if (expiresAt == null || expiresAt <= System.currentTimeMillis() / 1000) {
+                    throw new ServiceException(MessageUtils.message("no.oauth.token.expired", "Google"));
+                }
+            }
             String sub = json.getString("sub");
             if (sub == null || sub.isEmpty()) {
                 log.warn("[OAuth][Google] sub 为空");
@@ -224,6 +251,13 @@ public class OAuthVerifyService {
         }
     }
 
+    private String requireClientId(String clientId, String providerName) {
+        if (clientId == null || clientId.isBlank()) {
+            throw new ServiceException(MessageUtils.message("no.oauth.client.config.invalid", providerName));
+        }
+        return clientId;
+    }
+
     /** 简单 GET;bearer 非空时带 Authorization 头(LINE 用)。带超时,防止 provider 慢/不可达拖垮线程。 */
     private String httpGet(String url, String bearer) throws Exception {
         try (CloseableHttpClient client = HttpClients.createDefault()) {

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

@@ -68,11 +68,18 @@ oauth:
     client-id: com.twanmsdyh.app
     # Apple 公钥地址(验签用,一般不改)
     jwks-url: https://appleid.apple.com/auth/keys
+    # 业务 App 独立 audience;未配置只影响对应新增渠道。
+    rider-client-id: "${APPLE_RIDER_CLIENT_ID:}"
+    merchant-client-id: "${APPLE_MERCHANT_CLIENT_ID:}"
   google:
     # Google OAuth client_id(校验 ID-Token 的 audience)
     client-id: com.twanmsdyh.app
     tokeninfo-url: https://oauth2.googleapis.com/tokeninfo
+    rider-client-id: "${GOOGLE_RIDER_CLIENT_ID:}"
+    merchant-client-id: "${GOOGLE_MERCHANT_CLIENT_ID:}"
   line:
+    # 兼容已发布用户 App 不带 provider 的授权回调地址。
+    legacy-redirect-uri: "${LINE_USER_LEGACY_REDIRECT_URI:https://foodieapi.waimai-paotui.com/auth/line/callback}"
     # 用 code 换 access_token 的端点(一般不改)
     token-url: https://api.line.me/oauth2/v2.1/token
     # 用 access_token 换用户信息的端点(一般不改)

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

@@ -182,6 +182,8 @@ no.oauth.tempkey.missing=登录凭证缺失,请重新登录
 no.oauth.tempkey.expired=登录凭证已过期,请重新登录
 no.oauth.phone.blank=手机号不能为空
 no.oauth.line.config.invalid=LINE登录配置不完整
+no.oauth.client.config.invalid={0}登录配置不完整
+no.oauth.state.invalid=登录授权已失效,请重新发起登录
 no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过

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

@@ -186,6 +186,8 @@ 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.client.config.invalid={0} login configuration is incomplete
+no.oauth.state.invalid=Login authorization has expired. Please start sign-in again.
 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

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -186,6 +186,8 @@ no.oauth.tempkey.missing=Thiếu thông tin đăng nhập, vui lòng đăng nh
 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.client.config.invalid=Cấu hình đăng nhập {0} chưa đầy đủ
+no.oauth.state.invalid=Phiên xác thực đã hết hiệu lực. Vui lòng đăng nhập lại.
 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

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

@@ -186,6 +186,8 @@ no.oauth.tempkey.missing=登录凭证缺失,请重新登录
 no.oauth.tempkey.expired=登录凭证已过期,请重新登录
 no.oauth.phone.blank=手机号不能为空
 no.oauth.line.config.invalid=LINE登录配置不完整
+no.oauth.client.config.invalid={0}登录配置不完整
+no.oauth.state.invalid=登录授权已失效,请重新发起登录
 no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过

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

@@ -186,6 +186,8 @@ no.oauth.tempkey.missing=登入憑證缺失,請重新登入
 no.oauth.tempkey.expired=登入憑證已過期,請重新登入
 no.oauth.phone.blank=手機號碼不可為空
 no.oauth.line.config.invalid=LINE登入設定不完整
+no.oauth.client.config.invalid={0}登入設定不完整
+no.oauth.state.invalid=登入授權已失效,請重新發起登入
 no.oauth.token.invalid={0}登入憑證無效
 no.oauth.token.expired={0}登入憑證已過期
 no.oauth.audience.mismatch={0}憑證校驗未通過

+ 182 - 24
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -11,7 +11,9 @@ 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.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUserOauth;
 import com.ruoyi.system.domain.PosOrder;
@@ -26,6 +28,8 @@ 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.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
@@ -44,7 +48,6 @@ 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;
@@ -67,7 +70,7 @@ class InfoUserControllerTest {
     private MerchantStoreAccessService merchantStoreAccessService;
     private MerchantTokenSessionService merchantTokenSessionService;
     private BusinessPhoneService businessPhoneService;
-    private LineOAuthProperties lineOAuthProperties;
+    private OAuthVerifyService oauthVerifyService;
     private InfoUserOauthMapper infoUserOauthMapper;
     private RedisCache redisCache;
     private static ConfigurableListableBeanFactory originalBeanFactory;
@@ -78,6 +81,8 @@ class InfoUserControllerTest {
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
         TableInfoHelper.initTableInfo(
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), InfoUser.class);
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), InfoUserOauth.class);
         originalBeanFactory = (ConfigurableListableBeanFactory)
                 ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
         DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
@@ -90,6 +95,9 @@ class InfoUserControllerTest {
         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.user.not.exist", Locale.getDefault(), "账号不存在");
+        messageSource.addMessage("no.system.error", Locale.getDefault(), "系统错误");
+        messageSource.addMessage("no.user.jcaptcha.error", Locale.getDefault(), "验证码错误");
         messageSource.addMessage("no.oauth.tempkey.expired", Locale.getDefault(), "登录凭证已过期");
         beanFactory.registerSingleton("messageSource", messageSource);
         beanFactory.registerSingleton("redisCache", mock(RedisCache.class));
@@ -114,7 +122,7 @@ class InfoUserControllerTest {
         merchantStoreAccessService = mock(MerchantStoreAccessService.class);
         merchantTokenSessionService = mock(MerchantTokenSessionService.class);
         businessPhoneService = mock(BusinessPhoneService.class);
-        lineOAuthProperties = mock(LineOAuthProperties.class);
+        oauthVerifyService = mock(OAuthVerifyService.class);
         infoUserOauthMapper = mock(InfoUserOauthMapper.class);
         redisCache = mock(RedisCache.class);
         ReflectionTestUtils.setField(controller, "posOrderService", posOrderService);
@@ -123,7 +131,7 @@ class InfoUserControllerTest {
         ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchantStoreAccessService);
         ReflectionTestUtils.setField(controller, "merchantTokenSessionService", merchantTokenSessionService);
         ReflectionTestUtils.setField(controller, "businessPhoneService", businessPhoneService);
-        ReflectionTestUtils.setField(controller, "lineOAuthProperties", lineOAuthProperties);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
         ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
         ReflectionTestUtils.setField(controller, "redisCache", redisCache);
         when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
@@ -360,8 +368,6 @@ class InfoUserControllerTest {
         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);
 
@@ -379,8 +385,6 @@ class InfoUserControllerTest {
         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");
@@ -426,10 +430,6 @@ class InfoUserControllerTest {
         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);
@@ -445,8 +445,6 @@ class InfoUserControllerTest {
         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);
@@ -482,17 +480,177 @@ class InfoUserControllerTest {
         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);
+    @ParameterizedTest
+    @CsvSource({"apple_rider,2,qtw_tokens:qs:", "google_rider,2,qtw_tokens:qs:",
+            "apple_merchant,1,qtw_tokens:sh:app:", "google_merchant,3,qtw_tokens:sh:app:"})
+    void businessOauthBindsExistingBusinessPhoneAndIssuesItsOwnSession(
+            String provider, String role, String tokenPrefix) {
+        OAuthBindDto request = oauthRequest(provider);
+        InfoUser business = activeUser(72L, role);
+        business.setTelPhone(request.getPhone());
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(business);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        String token = (String) result.get("token");
+        assertTrue(JWT.decode(token).getId().startsWith(tokenPrefix));
+        assertEquals(provider, JWT.decode(token).getClaim("provider").asString());
+        ArgumentCaptor<InfoUserOauth> binding = ArgumentCaptor.forClass(InfoUserOauth.class);
+        verify(infoUserOauthMapper).insert(binding.capture());
+        assertEquals(72L, binding.getValue().getUserId());
+        assertEquals(provider, binding.getValue().getProvider());
+        verify(infoUserService, never()).getuser(any());
+        ArgumentCaptor<Wrapper<InfoUser>> query = ArgumentCaptor.forClass(Wrapper.class);
+        verify(infoUserService).getOne(query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("tel_phone"));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple_rider", "google_rider", "apple_merchant", "google_merchant"})
+    void businessOauthCannotCreateAnAccount(String provider) {
+        AjaxResult result = controller.oauthBindPhone(oauthRequest(provider));
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        assertEquals("账号不存在", result.get(AjaxResult.MSG_TAG));
+        verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple_rider,1", "google_rider,0", "apple_merchant,2", "google_merchant,0"})
+    void businessOauthRejectsBindingToTheWrongRole(String provider, String role) {
+        OAuthBindDto request = oauthRequest(provider);
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(72L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        when(infoUserService.getById(72L)).thenReturn(activeUser(72L, role));
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        assertNull(result.get("token"));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple", "google"})
+    void existingUserOauthKeepsProviderAndUserSession(String provider) {
+        OAuthBindDto request = oauthRequest(provider);
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(72L);
+        InfoUser user = activeUser(72L, "0");
+        user.setPhone(request.getPhone());
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        when(infoUserService.getById(72L)).thenReturn(user);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        String token = (String) result.get("token");
+        assertTrue(JWT.decode(token).getId().startsWith("qtw_tokens:user:"));
+        assertEquals(provider, JWT.decode(token).getClaim("provider").asString());
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple_rider,2,qtw_tokens:qs:", "google_rider,2,qtw_tokens:qs:",
+            "apple_merchant,4,qtw_tokens:sh:app:", "google_merchant,1,qtw_tokens:sh:app:",
+            "apple,0,qtw_tokens:user:", "google,0,qtw_tokens:user:", "line,0,qtw_tokens:user:",
+            "line_user,0,qtw_tokens:user:"})
+    void boundOauthLoginKeepsEachClientSession(String provider, String role, String tokenPrefix) {
+        OAuthLoginDto request = new OAuthLoginDto();
+        request.setProvider(provider);
+        request.setCredential("credential");
+        when(oauthVerifyService.verify(provider, "credential")).thenReturn("provider-uid");
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(72L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser user = activeUser(72L, role);
+        user.setPhone("0912345678");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+
+        AjaxResult result = controller.oauthLogin(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        assertTrue(JWT.decode((String) result.get("token")).getId().startsWith(tokenPrefix));
+        assertEquals(provider, JWT.decode((String) result.get("token")).getClaim("provider").asString());
+        if (provider.startsWith("line")) {
+            ArgumentCaptor<LambdaQueryWrapper<InfoUserOauth>> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+            verify(infoUserOauthMapper).selectOne(query.capture());
+            query.getValue().getSqlSegment();
+            assertTrue(query.getValue().getParamNameValuePairs().containsValue("line"));
+            assertTrue(query.getValue().getParamNameValuePairs().containsValue("line_user"));
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple", "google"})
+    void userFirstBindingStillCreatesAUserAndWallet(String provider) {
+        OAuthBindDto request = oauthRequest(provider);
+        InfoUser user = activeUser(72L, "0");
+        user.setPhone(request.getPhone());
+        when(infoUserService.getuser(request.getPhone())).thenReturn(null, user);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<InfoUser> created = ArgumentCaptor.forClass(InfoUser.class);
+        verify(infoUserService).saveOrUpdate(created.capture());
+        assertEquals("0", created.getValue().getUserType());
+        assertEquals(request.getPhone(), created.getValue().getPhone());
+        verify(userWalletService).createUserWallet(72L);
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple_merchant", "google_merchant"})
+    void businessOauthRejectsSubaccountWhoseOwnerIsUnavailable(String provider) {
+        OAuthBindDto request = oauthRequest(provider);
+        InfoUser user = activeUser(72L, "5");
+        user.setSubaccountStatus("0");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        doThrow(new ServiceException("owner unavailable")).when(merchantStoreAccessService).resolve(72L);
+        AjaxResult result = controller.oauthBindPhone(request);
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void directBusinessLineLoginCannotBypassStateValidation() {
+        OAuthLoginDto request = new OAuthLoginDto();
+        request.setProvider("line_rider");
+        request.setCredential("code");
+        LineOAuthStateService stateService = mock(LineOAuthStateService.class);
+        doThrow(new ServiceException("invalid state")).when(stateService).consume("line_rider", null);
+        ReflectionTestUtils.setField(controller, "lineOAuthStateService", stateService);
+        assertThrows(ServiceException.class, () -> controller.oauthLogin(request));
+        verify(oauthVerifyService, never()).verify(any(), any());
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple_rider", "google_merchant", "line_rider", "line_merchant"})
+    void businessBindingRequiresAnActualSmsCode(String provider) {
+        OAuthBindDto request = oauthRequest(provider);
+        request.setCode("8888");
+        InfoUser user = activeUser(72L, provider.endsWith("rider") ? "2" : "1");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        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));
     }
 
-    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 OAuthBindDto oauthRequest(String provider) {
+        OAuthBindDto request = new OAuthBindDto();
+        request.setTempKey("business-key");
+        request.setPhone("0912345678");
+        request.setCode("456789");
+        when(redisCache.getCacheObject("oauth:bind:business-key"))
+                .thenReturn(provider + "@provider-uid");
+        when(redisCache.deleteObject("oauth:bind:business-key")).thenReturn(true);
+        when(redisCache.getCacheObject("0912345678")).thenReturn("456789");
+        return request;
     }
 
     private InfoUser activeUser(Long userId, String userType) {

+ 50 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java

@@ -3,6 +3,7 @@ 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.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.common.constant.CacheConstants;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
@@ -73,6 +74,7 @@ class LineCallbackControllerTest {
         when(verifyService.verify("line_rider", "code")).thenReturn("line-uid");
         when(oauthMapper.selectOne(any())).thenReturn(null);
         LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "lineOAuthStateService", mock(LineOAuthStateService.class));
         ReflectionTestUtils.setField(controller, "oauthVerifyService", verifyService);
         ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
         ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
@@ -96,7 +98,7 @@ class LineCallbackControllerTest {
         fixture.controller.callback("line_rider", "code", "state", fixture.response);
 
         String redirected = fixture.response.getRedirectedUrl();
-        String token = URLDecoder.decode(redirected.substring(redirected.indexOf("?token=") + 7),
+        String token = URLDecoder.decode(redirected.substring(redirected.indexOf("?token=") + 7).split("&", 2)[0],
                 StandardCharsets.UTF_8);
         assertTrue(JWT.decode(token).getId().startsWith(CacheConstants.QS_TOKEN_KEY + "42:"));
         assertEquals("line_rider", JWT.decode(token).getClaim("provider").asString());
@@ -109,7 +111,7 @@ class LineCallbackControllerTest {
 
         fixture.controller.callback("line_rider", "code", "state", fixture.response);
 
-        assertEquals("com.twanmsdqs.app://pages/UserCenter/oauthLogin?error=user_stopped",
+        assertEquals("com.twanmsdqs.app://pages/UserCenter/oauthLogin?error=user_stopped&state=state",
                 fixture.response.getRedirectedUrl());
         verify(fixture.redisCache, never()).deleteKeys(any(String.class));
     }
@@ -150,6 +152,7 @@ class LineCallbackControllerTest {
         doThrow(new ServiceException("owner unavailable"))
                 .when(accessService).resolve(55L);
         LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "lineOAuthStateService", mock(LineOAuthStateService.class));
         ReflectionTestUtils.setField(controller, "oauthVerifyService", verifyService);
         ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
         ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
@@ -160,10 +163,53 @@ class LineCallbackControllerTest {
 
         controller.callback("line_merchant", "code", "state", response);
 
-        assertEquals("com.twanmsdsj.app://pages/UserCenter/oauthLogin?error=user_stopped",
+        assertEquals("com.twanmsdsj.app://pages/UserCenter/oauthLogin?error=user_stopped&state=state",
                 response.getRedirectedUrl());
     }
 
+    @Test
+    void oldUserCallbackWithoutProviderKeepsOriginalResponse() throws Exception {
+        LineOAuthProperties properties = new LineOAuthProperties();
+        LineOAuthProperties.Channel user = new LineOAuthProperties.Channel();
+        user.setClientId("user-client");
+        user.setClientSecret("secret");
+        user.setRedirectUri("https://api.test/auth/line/callback?provider=line_user");
+        user.setAppRedirect("com.twanmsdyh.app://pages/UserCenter/oauthLogin");
+        properties.setUser(user);
+        OAuthVerifyService verifier = mock(OAuthVerifyService.class);
+        when(verifier.verify("line", "old-code")).thenReturn("old-user-id");
+        RedisCache redis = mock(RedisCache.class);
+        LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", verifier);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", mock(InfoUserOauthMapper.class));
+        ReflectionTestUtils.setField(controller, "redisCache", redis);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        controller.callback(null, "old-code", "line_login", response);
+
+        assertTrue(response.getRedirectedUrl().startsWith("com.twanmsdyh.app://pages/UserCenter/oauthLogin?needPhone=1&tempKey="));
+        assertTrue(!response.getRedirectedUrl().contains("&state="));
+        verify(verifier).verify("line", "old-code");
+        verify(redis).setCacheObject(any(String.class), org.mockito.ArgumentMatchers.eq("line@old-user-id"),
+                org.mockito.ArgumentMatchers.eq(5), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void businessCallbackRejectsInvalidStateBeforeIssuingSession() throws Exception {
+        CallbackFixture fixture = boundFixture("2");
+        LineOAuthStateService stateService = mock(LineOAuthStateService.class);
+        doThrow(new ServiceException("invalid state")).when(stateService).consume("line_rider", "state");
+        ReflectionTestUtils.setField(fixture.controller, "lineOAuthStateService", stateService);
+        OAuthVerifyService verifier = (OAuthVerifyService) ReflectionTestUtils.getField(fixture.controller, "oauthVerifyService");
+
+        fixture.controller.callback("line_rider", "code", "state", fixture.response);
+
+        assertEquals("com.twanmsdqs.app://pages/UserCenter/oauthLogin?error=fail&state=state", fixture.response.getRedirectedUrl());
+        verify(verifier, never()).verify(any(), any());
+        verify(fixture.redisCache, never()).deleteKeys(any(String.class));
+    }
+
     private CallbackFixture boundFixture(String userType) {
         OAuthVerifyService verifyService = mock(OAuthVerifyService.class);
         IInfoUserService infoUserService = mock(IInfoUserService.class);
@@ -199,6 +245,7 @@ class LineCallbackControllerTest {
         when(infoUserService.getOne(any())).thenReturn(user);
 
         LineCallbackController controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "lineOAuthStateService", mock(LineOAuthStateService.class));
         ReflectionTestUtils.setField(controller, "oauthVerifyService", verifyService);
         ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
         ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);

+ 14 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/LineOAuthPropertiesTest.java

@@ -41,13 +41,12 @@ class LineOAuthPropertiesTest {
     }
 
     @Test
-    void rejectsLegacyOrUnknownLineProvider() {
+    void rejectsUnknownLineProvider() {
         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"));
         }
     }
@@ -73,6 +72,19 @@ class LineOAuthPropertiesTest {
         return properties;
     }
 
+    @Test
+    void legacyUserKeepsOriginalRedirectUriAndUserChannel() {
+        LineOAuthProperties properties = properties();
+        LineOAuthProperties.ResolvedChannel legacy = properties.requireChannel("line");
+        assertEquals("https://api.test/auth/line/callback", legacy.redirectUri());
+        assertEquals("user-client", legacy.clientId());
+        assertEquals(CacheConstants.USER_TOKEN_KEY, legacy.tokenKey());
+        properties.setLegacyRedirectUri("https://old.test/auth/line/callback");
+        assertEquals("https://old.test/auth/line/callback", properties.requireChannel("line").redirectUri());
+        assertEquals("https://api.test/auth/line/callback?provider=line_user",
+                properties.requireChannel("line_user").redirectUri());
+    }
+
     private LineOAuthProperties.Channel channel(String name) {
         LineOAuthProperties.Channel channel = new LineOAuthProperties.Channel();
         channel.setClientId(name + "-client");

+ 58 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/LineOAuthStateServiceTest.java

@@ -0,0 +1,58 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.ruoyi.common.core.redis.RedisCache;
+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.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class LineOAuthStateServiceTest {
+    @Test
+    void stateIsBoundToProviderAndCanBeConsumedOnlyOnce() {
+        RedisCache redis = mock(RedisCache.class);
+        Map<String, Object> cache = new ConcurrentHashMap<>();
+        doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
+                .when(redis).setCacheObject(anyString(), any(), eq(5), eq(TimeUnit.MINUTES));
+        when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
+        when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
+        LineOAuthProperties properties = new LineOAuthProperties();
+        LineOAuthProperties.Channel rider = new LineOAuthProperties.Channel();
+        rider.setClientId("rider-client");
+        rider.setClientSecret("test-secret");
+        rider.setRedirectUri("https://api.test/auth/line/callback?provider=line_rider");
+        rider.setAppRedirect("rider://login");
+        properties.setRider(rider);
+        LineOAuthStateService service = new LineOAuthStateService(properties, redis);
+
+        var authorization = service.authorize("line_rider");
+        assertTrue(authorization.state().matches("[a-f0-9]{32}"));
+        assertTrue(authorization.authorizationUrl().contains("state=" + authorization.state()));
+        assertTrue(authorization.authorizationUrl().contains("client_id=rider-client"));
+        assertTrue(authorization.authorizationUrl().contains("redirect_uri=https%3A%2F%2Fapi.test%2Fauth%2Fline%2Fcallback%3Fprovider%3Dline_rider"));
+        assertFalse(authorization.authorizationUrl().contains("test-secret"));
+        try (MockedStatic<MessageUtils> ignored = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.consume("line_merchant", authorization.state()));
+            assertDoesNotThrow(() -> service.consume("line_rider", authorization.state()));
+            assertThrows(ServiceException.class, () -> service.consume("line_rider", authorization.state()));
+            assertThrows(ServiceException.class, () -> service.consume("line_rider", null));
+        }
+    }
+
+    @Test
+    void oldUserClientsDoNotRequireServerState() {
+        RedisCache redis = mock(RedisCache.class);
+        LineOAuthStateService service = new LineOAuthStateService(new LineOAuthProperties(), redis);
+        service.consume("line", null);
+        service.consume("line_user", "line_login");
+        service.consume("apple", null);
+        verifyNoInteractions(redis);
+    }
+}

+ 178 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/oauth/OAuthVerifyServiceTest.java

@@ -0,0 +1,178 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mockStatic;
+
+class OAuthVerifyServiceTest {
+    private HttpServer server;
+    private OAuthVerifyService service;
+    private MockedStatic<MessageUtils> messages;
+    private final AtomicReference<String> googleResponse = new AtomicReference<>();
+    private final AtomicReference<String> jwksResponse = new AtomicReference<>();
+
+    @BeforeEach
+    void setUp() throws Exception {
+        messages = mockStatic(MessageUtils.class);
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        respond("/google", googleResponse);
+        respond("/keys", jwksResponse);
+        server.start();
+        String base = "http://127.0.0.1:" + server.getAddress().getPort();
+        service = new OAuthVerifyService();
+        ReflectionTestUtils.setField(service, "googleTokeninfoUrl", base + "/google");
+        ReflectionTestUtils.setField(service, "appleJwksUrl", base + "/keys");
+        ReflectionTestUtils.setField(service, "appleClientId", "user-apple");
+        ReflectionTestUtils.setField(service, "googleClientId", "user-google");
+        ReflectionTestUtils.setField(service, "appleRiderClientId", "rider-apple");
+        ReflectionTestUtils.setField(service, "appleMerchantClientId", "merchant-apple");
+        ReflectionTestUtils.setField(service, "googleRiderClientId", "rider-google");
+        ReflectionTestUtils.setField(service, "googleMerchantClientId", "merchant-google");
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (server != null) server.stop(0);
+        if (messages != null) messages.close();
+    }
+
+    @ParameterizedTest
+    @CsvSource({"google,user-google", "google_rider,rider-google", "google_merchant,merchant-google"})
+    void googleUsesOnlyTheRequestedAppAudience(String provider, String audience) {
+        googleResponse.set(googleClaims(audience, "https://accounts.google.com", future()));
+        assertEquals("google-sub", service.verify(provider, "test-token"));
+        googleResponse.set(googleClaims("other-client", "https://accounts.google.com", future()));
+        assertThrows(ServiceException.class, () -> service.verify(provider, "test-token"));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"apple,user-apple", "apple_rider,rider-apple", "apple_merchant,merchant-apple"})
+    void appleVerifiesSignatureAndOnlyTheRequestedAppAudience(String provider, String audience) throws Exception {
+        RSAKey key = new RSAKeyGenerator(2048).keyID("apple-key").generate();
+        jwksResponse.set(new JWKSet(key.toPublicJWK()).toString());
+        assertEquals("apple-sub", service.verify(provider, appleToken(key, audience, "https://appleid.apple.com", future())));
+        String wrongAudience = appleToken(key, "other-client", "https://appleid.apple.com", future());
+        assertThrows(ServiceException.class, () -> service.verify(provider, wrongAudience));
+        RSAKey wrongKey = new RSAKeyGenerator(2048).keyID("apple-key").generate();
+        String forged = appleToken(wrongKey, audience, "https://appleid.apple.com", future());
+        assertThrows(ServiceException.class, () -> service.verify(provider, forged));
+    }
+
+    @Test
+    void businessGoogleRejectsWrongIssuerAndExpiredTokens() {
+        googleResponse.set(googleClaims("rider-google", "https://attacker.test", future()));
+        assertThrows(ServiceException.class, () -> service.verify("google_rider", "test-token"));
+        googleResponse.set(googleClaims("rider-google", "accounts.google.com", 1));
+        assertThrows(ServiceException.class, () -> service.verify("google_rider", "test-token"));
+    }
+
+    @Test
+    void appleRejectsWrongIssuerAndExpiredTokens() throws Exception {
+        RSAKey key = new RSAKeyGenerator(2048).keyID("apple-key").generate();
+        jwksResponse.set(new JWKSet(key.toPublicJWK()).toString());
+        String wrongIssuer = appleToken(key, "rider-apple", "https://attacker.test", future());
+        String expired = appleToken(key, "rider-apple", "https://appleid.apple.com", 1);
+        assertThrows(ServiceException.class, () -> service.verify("apple_rider", wrongIssuer));
+        assertThrows(ServiceException.class, () -> service.verify("apple_rider", expired));
+    }
+
+    @Test
+    void unconfiguredBusinessClientsDoNotDisableExistingUserLogin() throws Exception {
+        ReflectionTestUtils.setField(service, "googleRiderClientId", "");
+        ReflectionTestUtils.setField(service, "appleMerchantClientId", "");
+        assertThrows(ServiceException.class, () -> service.verify("google_rider", "test-token"));
+        assertThrows(ServiceException.class, () -> service.verify("apple_merchant", "test-token"));
+        googleResponse.set(googleClaims("user-google", "accounts.google.com", future()));
+        assertEquals("google-sub", service.verify("google", "test-token"));
+        RSAKey key = new RSAKeyGenerator(2048).keyID("apple-key").generate();
+        jwksResponse.set(new JWKSet(key.toPublicJWK()).toString());
+        assertEquals("apple-sub", service.verify("apple", appleToken(key, "user-apple", "https://appleid.apple.com", future())));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"line,https://api.test/auth/line/callback", "line_user,https://api.test/auth/line/callback?provider=line_user",
+            "line_rider,https://api.test/auth/line/callback?provider=line_rider",
+            "line_merchant,https://api.test/auth/line/callback?provider=line_merchant"})
+    void lineExchangesCodeUsingTheExactAppRedirect(String provider, String redirect) {
+        AtomicReference<String> submittedForm = new AtomicReference<>();
+        AtomicReference<String> bearer = new AtomicReference<>();
+        server.createContext("/line/token", exchange -> {
+            submittedForm.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+            byte[] body = "{\"access_token\":\"test-access\",\"scope\":\"profile\"}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            try (var output = exchange.getResponseBody()) { output.write(body); }
+        });
+        server.createContext("/line/profile", exchange -> {
+            bearer.set(exchange.getRequestHeaders().getFirst("Authorization"));
+            byte[] body = "{\"userId\":\"line-sub\"}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            try (var output = exchange.getResponseBody()) { output.write(body); }
+        });
+        String base = "http://127.0.0.1:" + server.getAddress().getPort();
+        LineOAuthProperties properties = new LineOAuthProperties();
+        properties.setTokenUrl(base + "/line/token");
+        properties.setProfileUrl(base + "/line/profile");
+        String channelName = provider.equals("line") ? "line_user" : provider;
+        LineOAuthProperties.Channel channel = new LineOAuthProperties.Channel();
+        channel.setClientId(channelName + "-client");
+        channel.setClientSecret(channelName + "-secret");
+        channel.setRedirectUri("https://api.test/auth/line/callback?provider=" + channelName);
+        channel.setAppRedirect("app://login");
+        properties.setUser(channel);
+        properties.setRider(channel);
+        properties.setMerchant(channel);
+        ReflectionTestUtils.setField(service, "lineOAuthProperties", properties);
+
+        assertEquals("line-sub", service.verify(provider, "test-code"));
+
+        assertTrue(submittedForm.get().contains("redirect_uri=" + java.net.URLEncoder.encode(redirect, StandardCharsets.UTF_8)));
+        assertTrue(submittedForm.get().contains("client_id=" + channelName + "-client"));
+        assertTrue(submittedForm.get().contains("client_secret=" + channelName + "-secret"));
+        assertEquals("Bearer test-access", bearer.get());
+    }
+
+    private void respond(String path, AtomicReference<String> response) {
+        server.createContext(path, exchange -> {
+            byte[] body = response.get().getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            try (var output = exchange.getResponseBody()) { output.write(body); }
+        });
+    }
+
+    private long future() { return System.currentTimeMillis() / 1000 + 3600; }
+
+    private String googleClaims(String aud, String iss, long exp) {
+        return "{\"aud\":\"" + aud + "\",\"iss\":\"" + iss + "\",\"exp\":" + exp + ",\"sub\":\"google-sub\"}";
+    }
+
+    private String appleToken(RSAKey key, String aud, String iss, long exp) throws Exception {
+        SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("apple-key").build(),
+                new JWTClaimsSet.Builder().subject("apple-sub").audience(aud).issuer(iss)
+                        .expirationTime(new Date(exp * 1000)).build());
+        jwt.sign(new RSASSASigner(key));
+        return jwt.serialize();
+    }
+}

+ 7 - 0
specs/017-oauth-login/line-callback-frontend.md

@@ -1,5 +1,7 @@
 # LINE 登录回调 — 前端(uniapp)接入说明
 
+> **2026-09-08:骑手、商家以 [最新接入说明](oauth-app-frontend.md) 为准,先获取服务端 state。** 本文旧授权示例仅用于说明参数。用户旧 `line` 与无 provider 回调继续兼容,不要求用户 App 同步升级。
+
 > 配套后端:`LineCallbackController` → `GET /auth/line/callback`(017-oauth-login FR-009~FR-015)。
 > 适用场景:**「唤起 LINE App / 系统浏览器」** 授权 —— 这种方式前端拿不到 code,由后端接住 LINE 的重定向、完成登录后,再 302 跳回 App。
 >
@@ -217,3 +219,8 @@ if (params.error) {
 4. **设备信息**:后端回调链路没有 App 上下文,`cid` / `deviceToken` / `voIPToken` 在回调时更新不到;绑手机走 `/oauthBindPhone` 时会带上,已绑定的老用户建议 5.1 拿到 token 后补报一次(或复用既有设备上报逻辑)。
 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` 对应一致。
+# 2026-09-08 兼容与业务端协议增量
+
+骑手、商家最新完整接入以 [oauth-app-frontend.md](oauth-app-frontend.md) 为准:业务端先调用 `/auth/line/authorize` 获取一次性 state,回调或自行提交 code 时均校验并消费;App 回跳必须比较 state。以下原授权 URL 示例仅保留用于理解渠道参数,不得继续用于业务端固定 state 登录。
+
+用户旧 `provider=line` 与不带 provider 的原回调地址继续兼容,用户原协议无需同步升级;新用户端 `line_user` 也继续支持。

+ 81 - 0
specs/017-oauth-login/oauth-app-frontend.md

@@ -0,0 +1,81 @@
+# 骑手、商家快捷登录接入(2026-09-08)
+
+## 兼容边界
+
+用户端继续使用原 `apple`、`google`、`line` 或已升级的 `line_user`,请求/响应结构与用户会话不变。旧 LINE 回调 `/auth/line/callback` 不带 provider 时按旧用户渠道处理,换 token 使用不带 provider 的原地址;`line` 和 `line_user` 绑定均可读取。不得要求用户 App 同步发版或更换原 client ID。
+
+本次新增渠道如下;首次只关联已有业务账号,不创建骑手或商家。
+
+| App | Apple | Google | LINE | 手机号 | 会话 |
+|---|---|---|---|---|---|
+| 骑手 | apple_rider | google_rider | line_rider | tel_phone,userType=2 | QS_TOKEN_KEY |
+| 商家 | apple_merchant | google_merchant | line_merchant | tel_phone,userType=1/3/4/5 | SH_APP_TOKEN_KEY |
+
+## Apple / Google
+
+原生 SDK 取得 Apple identityToken 或 Google ID token 后:
+
+```http
+POST /infouser/user/oauthLogin
+Content-Type: application/json
+
+{"provider":"apple_rider","credential":"<identityToken>","cid":"<设备推送标识>","cidType":"<原有类型>","deviceToken":"<设备token>","voIPToken":"<语音推送token>"}
+```
+
+已绑定返回 `{code:200,msg,data,token}`,商家返回现有商家登录视图。未绑定返回 `{code:200,msg,data:{status:"needPhone",tempKey}}`,进入手机验证页。
+
+绑定接口保持不变,端信息已保存在一次性 tempKey 中,禁止 App 自行指定角色或用户 ID:
+
+```http
+POST /infouser/user/oauthBindPhone
+Content-Type: application/json
+
+{"tempKey":"<临时凭证>","phone":"<业务账号手机号>","code":"<短信验证码>","cid":"<设备推送标识>"}
+```
+
+账号不存在时引导使用已有账号/联系管理员;不得在 OAuth 页面自动注册业务账号。业务账号必须验证真实短信码,不能使用固定测试码。tempKey 五分钟有效且单次消费,绑定失败须重新发起第三方登录。密码、手机号登录入口仍保留。
+
+## 业务 App LINE:先获取 state
+
+```http
+POST /auth/line/authorize
+Content-Type: application/json
+
+{"provider":"line_rider"}
+```
+
+返回 `{code:200,data:{authorizationUrl,state}}`。App 保存 state 并打开服务端返回的 authorizationUrl,不替换 redirect_uri 或 Channel ID。
+
+- 骑手回调:`/auth/line/callback?provider=line_rider`,回跳 `com.twanmsdqs.app://pages/UserCenter/oauthLogin`。
+- 商家回调:`/auth/line/callback?provider=line_merchant`,回跳 `com.twanmsdsj.app://pages/UserCenter/oauthLogin`。
+- 服务端验证 state 属于当前 provider 且未过期/消费,再交换 code。成功/失败回跳都携带 state;App 必须先与本地发起值比较,不匹配时不得保存 token 或使用 tempKey。
+- 已绑定:`?token=...&state=...`;未绑定:`?needPhone=1&tempKey=...&state=...`;失败/取消:`?error=...&state=...`。处理后清除本地 state,并防止重复处理同一个回跳。
+- 若 App 在请求到服务端前拦截 code,则先校验回调 state,再调用 `/infouser/user/oauthLogin`,携带 `{provider,credential:code,state}`。只选择一种 code 消费路径,不能同时访问回调又提交 oauthLogin。
+- 用户旧 LINE 登录不强制 state 新协议。本轮不改用户 App 的固定 state 或既有回跳处理。
+
+按钮、取消、配置缺失、绑定失败等新增文案均须使用现有四语言 i18n。无 SDK 能力/配置时不得展示可点击但无法授权的入口。
+
+## 部署配置(不要写入客户端 Secret)
+
+| 环境变量 | 用途 |
+|---|---|
+| APPLE_RIDER_CLIENT_ID | 骑手 Apple identityToken 的 audience |
+| APPLE_MERCHANT_CLIENT_ID | 商家 Apple identityToken 的 audience |
+| GOOGLE_RIDER_CLIENT_ID | 骑手 Google ID token 的 audience(服务端 OAuth client ID) |
+| GOOGLE_MERCHANT_CLIENT_ID | 商家 Google ID token 的 audience(服务端 OAuth client ID) |
+| LINE_USER_CLIENT_SECRET | 现有用户 LINE Channel Secret,发布时保持有效配置 |
+| LINE_RIDER_CLIENT_SECRET | 骑手 LINE Channel Secret |
+| LINE_MERCHANT_CLIENT_SECRET | 商家 LINE Channel Secret |
+| LINE_USER_LEGACY_REDIRECT_URI | 旧用户 LINE 原授权地址,默认现有生产 `/auth/line/callback` |
+
+四个新增 client ID 默认为空,缺失只拒绝对应新增渠道;不覆盖 `oauth.apple.client-id` / `oauth.google.client-id` 的原用户配置。Google client ID 应与 SDK 请求 ID token 所用值一致,不能直接把 Android 包名当作 OAuth client ID。
+
+渠道校验依据:[Apple 身份校验](https://developer.apple.com/documentation/signinwithapple/verifying-a-user)、[Google 后端校验](https://developers.google.com/identity/sign-in/android/backend-auth)、[LINE 授权协议](https://developers.line.biz/en/docs/line-login/integrate-line-login/)。
+
+## 验收
+
+1. 用户旧 App 的 Apple、Google、LINE:已绑定登录、首次绑定、退出重登;旧 LINE 无 provider 回调及旧绑定仍可用。
+2. 骑手和普通商家/夜市/子账号分别完成三渠道首次绑定与重复登录;真实手机号验证码、无账号拒绝、停用账号拒绝、子账号归属失效拒绝。
+3. 同一手机号同时有普通用户和业务账号时,各端登录后身份、token 和菜单权限正确;跨 App audience 与错误角色拒绝。
+4. LINE state 缺失、过期、跨端、重复回调均拒绝;App 回跳 state 不匹配时不写入会话。
+5. 未完成真实渠道和 App 验收前,不以本地自动化测试宣称功能已上线。

+ 11 - 1
specs/017-oauth-login/plan.md

@@ -1,5 +1,14 @@
 # 017 实施计划
 
+## 2026-09-08 执行增量
+
+1. 在 `InfoUserControllerTest` 增加苹果/谷歌业务端绑定、角色拒绝、token 分端与用户原协议回归;为凭证校验增加本地 HTTP / 签名 JWT 测试,先验证缺失行为。
+2. 新增 `OAuthAccountType` 集中描述 provider 对应业务角色;`InfoUserController` 复用现有绑定流程,保持原 apple/google 用户分支。`OAuthVerifyService` 新增独立业务端 client ID,缺失时仅拒绝对应新渠道。
+3. `LineOAuthProperties`、`LineCallbackController` 兼容旧用户 provider 与无参数回调;绑定查询兼容 line / line_user。不得改变原用户授权地址和强制旧 App 增加参数。
+4. 更新 application.yml 环境变量、DTO 注释、四语言错误与前端接入文档;SQL 仅写人工执行说明,不连接数据库修改数据。
+   - 业务 LINE 由 `LineOAuthStateService` 签发/单次消费 state;新增 `/auth/line/authorize` 与 DTO,回调及直接 code 登录均校验。用户旧协议不受强制校验影响。
+5. JDK 21 运行 OAuth 定向测试、关联账号回归和 `ruoyi-admin -am` 构建;记录真实 App / 渠道配置等外部验收限制。继续在当前 `test-202609v2` 工作区实施。
+
 ## 架构:最大化复用现有登录链路
 
 ```
@@ -45,9 +54,10 @@ CREATE TABLE info_user_oauth (
 ### 1) `POST /infouser/user/oauthLogin`  (`@Anonymous`)
 请求 `OAuthLoginDto`:
 ```
-provider     apple|google|line_user|line_rider|line_merchant
+provider     apple|google|line|line_user|apple_rider|google_rider|line_rider|apple_merchant|google_merchant|line_merchant
 credential   identityToken(Apple) / idToken(Google) / authorizationCode(LINE)
 cid, cidType, deviceToken, voIPToken   // 推送字段,与现有登录一致
+state        // 业务 LINE 自行提交 code 时必填,由 /auth/line/authorize 签发
 ```
 响应(命中):
 ```

+ 12 - 2
specs/017-oauth-login/spec.md

@@ -24,7 +24,7 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 
 ## Functional Requirements
 
-- **FR-001**: 提供 `POST /infouser/user/oauthLogin {provider, credential, ...}`,后端校验 provider 凭证换取稳定 providerUid。provider 支持 `apple`、`google`、`line_user`、`line_rider`、`line_merchant`。
+- **FR-001**: 提供 `POST /infouser/user/oauthLogin {provider, credential, ...}`,后端校验 provider 凭证换取稳定 providerUid。用户 provider 为 `apple`、`google`、`line`(旧版)、`line_user`;骑手为 `apple_rider`、`google_rider`、`line_rider`;商家为 `apple_merchant`、`google_merchant`、`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)`。
@@ -51,7 +51,17 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 - Apple 校验 iss=`https://appleid.apple.com` + exp。
 - tempKey 一次性、短 TTL(5 分钟),防重放。
 
-## 不在本期范围
+## 2026-09-08 增量:骑手、商家 Apple / Google 与用户端兼容
+
+- 新增 `apple_rider`、`apple_merchant`、`google_rider`、`google_merchant`,各端独立校验 audience,不回退到用户端 client ID。
+- 骑手仅允许已有有效 `userType=2`,商家仅允许已有有效 `userType=1/3/4/5`;通过 `tel_phone` 验证绑定,不自动创建业务账号。子账号继续校验启用状态与归属权限。
+- 业务账号首次绑定要求真实短信验证码,不使用原用户分支的固定验证码兼容逻辑;用户原校验行为不变。
+- 按端签发 `QS_TOKEN_KEY` / `SH_APP_TOKEN_KEY`,用户原有 `apple`、`google` 配置、绑定和 token 类型保持兼容。
+- 兼容用户 App 仍在使用的 `provider=line` 和不带 provider 的 `/auth/line/callback`,旧授权地址换 token 时使用原 redirect URI。`line` / `line_user` 均可读取旧、新绑定;不直接执行数据迁移。
+- 用户旧版不强制升级 state 协议;客户端升级与真实凭证验证须单独记录实际完成情况,不以单元测试代替上线验收。
+- 业务 LINE 新增 `POST /auth/line/authorize` 签发五分钟有效的 provider 绑定 state;回调和直接提交 code 均要求单次消费,App 比较回跳 state。用户旧 LINE 协议保持不变。
+
+## 不在本期范围(原记录)
 
 - 后台管理端解绑/查看三方绑定(后续增量)。
 - 第三家以上三方(微信/Facebook)——表结构已可扩展,但本期只接 Apple/Google/LINE。

+ 17 - 0
specs/017-oauth-login/tasks.md

@@ -1,5 +1,22 @@
 # 017 任务清单
 
+## 2026-09-08 增量(实现中)
+
+- [x] T23 骑手/商家 Apple、Google 独立凭证配置、角色绑定、会话签发及用户端兼容回归。
+- [x] T24 兼容用户旧 LINE provider、无 provider 回调与旧/新绑定数据。
+- [x] T25 更新三端接入文档、环境配置与 SQL 人工执行边界。
+- [ ] T26 JDK 21 定向测试、关联回归、模块构建与最终差异检查。
+- [x] T27 业务 LINE 服务端 state 签发/单次消费、两条 code 入口校验及旧用户协议兼容。
+- [ ] T28 骑手/商家 App 页面、SDK、四语言和回跳 state 比对接入;源码路径待提供。
+
+### 本轮验证记录
+
+- JDK 21 定向测试及 package:79 tests,0 failures/errors,所有 reactor 模块 SUCCESS。覆盖账号角色、短信绑定、用户原登录/注册、旧 LINE 协议、三端 audience、签名与 state。日志:`ruoyi-admin/target/oauth-final-verification.log`。
+- 另运行完整 `mvn -pl ruoyi-admin -am package`:`ruoyi-system` 83 tests 全通过,`ruoyi-admin` 419 tests 中 418 通过、1 error。仍是既有 `PosOrderQsOprateControllerTest` 未注入 `UserService` 导致 NPE,未修改该无关测试,T26 保留未勾选。日志:`ruoyi-admin/target/oauth-regression.log`。
+- 新业务账号绑定必须验证实际短信码,用户原校验行为保持兼容;固定短信码拒绝用例已先观察失败再验证修复。
+- 用户 App 源码未修改。业务 App 源码、真实渠道配置和真机端到端验证未完成;未执行任何数据库 SQL、部署或密钥轮换。
+- 既有实现已包含在 `af8cc41`;本轮增量保留工作区,尚未提交。
+
 > 顺序执行;每步完成后编译验证。
 
 - [x] T1 数据模型:`updatesql/sql.md` 加 `info_user_oauth` 建表(含 uk_provider_uid 唯一索引、idx_user_id)。

+ 17 - 0
updatesql/sql.md

@@ -1463,3 +1463,20 @@ VALUES (1,'臺灣銀行','臺灣銀行','taiwan_bank_list',NULL,'default','N','0
  (28,'星展(台灣)銀行','星展(台灣)銀行','taiwan_bank_list',NULL,'default','N','0','admin',NOW(),NULL),
  (29,'中華郵政(郵局)','中華郵政(郵局)','taiwan_bank_list',NULL,'default','N','0','admin',NOW(),NULL);
 ```
+# 2026-09-08:017 骑手/商家 Apple、Google 登录与旧用户 LINE 兼容
+
+本增量无 DDL,无需改写既有用户 Apple / Google / LINE 绑定。新增 provider 为
+`apple_rider`、`apple_merchant`、`google_rider`、`google_merchant`,长度均适配原 `VARCHAR(16)`。
+后端兼容 `line` 和 `line_user`,不要为了本次发布强制执行历史 LINE provider 迁移。
+`info_user_oauth` 表和业务账号 `tel_phone` 若尚未按此前脚本准备,由开发者按原预检流程处理。
+
+以下只读检查用于发现曾同时建立旧/新 LINE 绑定的重复数据;有结果时先人工核实,不自动删除、合并或改绑:
+
+```sql
+SELECT provider_uid, COUNT(*) AS binding_count,
+       COUNT(DISTINCT user_id) AS account_count
+FROM info_user_oauth
+WHERE provider IN ('line', 'line_user')
+GROUP BY provider_uid
+HAVING COUNT(*) > 1;
+```