Просмотр исходного кода

新增 Apple/Google/LINE 三方登录(017-oauth-login)

- 独立表 info_user_oauth 记录三方绑定(provider+provider_uid 唯一索引)
- OAuthVerifyService:Apple(RS256 JWKS 验签)/Google(tokeninfo)/LINE(v2-profile)校验凭证取 providerUid
- oauthLogin(已绑定直接登录/未绑定返 needPhone+tempKey)+ oauthBindPhone(手机验证→已注册关联/未注册新建→写绑定)
- 首次强制绑手机,手机号始终为主键;loginType 写入 JWT provider claim
- 新增 no.oauth.* i18n key(zh/zh_TW/en/vi/default 5 语言)
- 加 nimbus-jose-jwt 依赖;application.yml 加 oauth 配置(Apple client-id 已填)

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 4 дней назад
Родитель
Сommit
4efeb5948f

+ 7 - 0
ruoyi-admin/pom.xml

@@ -114,6 +114,13 @@
             <version>5.2.1</version>
         </dependency>
 
+        <!-- nimbus-jose-jwt:Apple Sign in 的 identityToken(ES256) 验签 + JWKS 解析 -->
+        <dependency>
+            <groupId>com.nimbusds</groupId>
+            <artifactId>nimbus-jose-jwt</artifactId>
+            <version>9.37.3</version>
+        </dependency>
+
         <!-- ZXing 二维码生成 -->
         <dependency>
             <groupId>com.google.zxing</groupId>

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

@@ -36,6 +36,10 @@ import com.ruoyi.system.service.IVipUserService;
 import com.ruoyi.system.utils.Auth;
 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.OAuthVerifyService;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
 import eu.bitwalker.useragentutils.UserAgent;
 import org.apache.commons.lang3.RandomStringUtils;
@@ -50,6 +54,10 @@ import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
@@ -83,6 +91,12 @@ public class InfoUserController extends BaseController {
     private RedisCache redisCache;
     @Autowired
     private IPosOrderService posOrderService;
+    @Autowired
+    private InfoUserOauthMapper infoUserOauthMapper;
+    @Autowired
+    private OAuthVerifyService oauthVerifyService;
+    /** 三方登录首登临时凭证 Redis 前缀 */
+    private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
 
 
     @Anonymous
@@ -885,6 +899,139 @@ public class InfoUserController extends BaseController {
         return success(MessageUtils.message("no.user.login.success"), user, token);
     }
 
+    // ==================== 三方登录(017-oauth-login)====================
+
+    /**
+     * 三方登录第一步:校验 provider 凭证 → 已绑定直接登录;未绑定返回 needPhone + tempKey。
+     */
+    @Anonymous
+    @PostMapping("/oauthLogin")
+    public AjaxResult oauthLogin(@RequestBody OAuthLoginDto dto) {
+        if (dto == null || dto.getProvider() == null || dto.getProvider().isEmpty()) {
+            return error(MessageUtils.message("no.oauth.provider.blank"));
+        }
+        String providerUid = oauthVerifyService.verify(dto.getProvider(), dto.getCredential());
+
+        // 查是否已绑定
+        InfoUserOauth bind = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .eq(InfoUserOauth::getProvider, dto.getProvider())
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (bind != null) {
+            // 已绑定:校验用户正常后直接登录
+            QueryWrapper<InfoUser> q = new QueryWrapper<>();
+            q.eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0");
+            InfoUser u = infoUserService.getOne(q);
+            if (u == null) {
+                return error(MessageUtils.message("no.user.stop"));
+            }
+            u.setCid(dto.getCid());
+            u.setCidType(dto.getCidType());
+            u.setDeviceToken(dto.getDeviceToken());
+            u.setVoIPToken(dto.getVoIPToken());
+            infoUserService.saveOrUpdate(u);
+            return issueOauthToken(u, dto.getProvider());
+        }
+
+        // 未绑定:缓存 {provider, providerUid},返回 needPhone + tempKey
+        String tempKey = UUID.randomUUID().toString().replace("-", "");
+        redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
+                dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
+        Map<String, Object> data = new HashMap<>();
+        data.put("status", "needPhone");
+        data.put("tempKey", tempKey);
+        return AjaxResult.success(MessageUtils.message("no.oauth.needphone"), data);
+    }
+
+    /**
+     * 三方登录第二步:手机号 + 短信验证 → 已注册关联 / 未注册新建 → 写绑定 → 签 token。
+     */
+    @Anonymous
+    @PostMapping("/oauthBindPhone")
+    public AjaxResult oauthBindPhone(@RequestBody OAuthBindDto dto) {
+        if (dto == null || dto.getTempKey() == null || dto.getTempKey().isEmpty()) {
+            return error(MessageUtils.message("no.oauth.tempkey.missing"));
+        }
+        String cached = redisCache.getCacheObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        if (cached == null) {
+            return error(MessageUtils.message("no.oauth.tempkey.expired"));
+        }
+        int at = cached.indexOf('@');
+        String provider = cached.substring(0, at);
+        String providerUid = cached.substring(at + 1);
+
+        // 容错:若期间已被绑定,直接登录
+        InfoUserOauth exist = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .eq(InfoUserOauth::getProvider, provider)
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (exist != null) {
+            InfoUser u = infoUserService.getById(exist.getUserId());
+            return issueOauthToken(u, provider);
+        }
+
+        // 验短信(复用 lodeing 逻辑:redis code 或万能码 8888)
+        String phone = dto.getPhone();
+        String xcode = redisCache.getCacheObject(phone.trim().replaceAll("\\+", ""));
+        boolean codeOk = (xcode != null && xcode.equals(dto.getCode())) || "8888".equals(dto.getCode());
+        if (!codeOk) {
+            return error(MessageUtils.message("no.user.jcaptcha.error"));
+        }
+
+        // 查/建用户(手机号始终为主键)
+        InfoUser user = infoUserService.getuser(phone);
+        if (user != null && !"0".equals(user.getStatus())) {
+            // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
+            return error(MessageUtils.message("no.user.stop"));
+        }
+        if (user == null) {
+            // 新建:昵称=手机号(与 createUser 一致)
+            InfoUser info = new InfoUser();
+            info.setPhone(phone);
+            info.setNickName(phone);
+            info.setCid(dto.getCid());
+            info.setCidType(dto.getCidType());
+            info.setDeviceToken(dto.getDeviceToken());
+            info.setVoIPToken(dto.getVoIPToken());
+            info.setUserType("0");
+            info.setMycode(new UUIDUtil().get8UUID());
+            infoUserService.saveOrUpdate(info);
+            user = infoUserService.getuser(phone);
+            createUserWallet(user.getUserId());
+        } else {
+            user.setCid(dto.getCid());
+            user.setCidType(dto.getCidType());
+            user.setDeviceToken(dto.getDeviceToken());
+            user.setVoIPToken(dto.getVoIPToken());
+            infoUserService.saveOrUpdate(user);
+        }
+
+        // 写三方绑定
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(user.getUserId());
+        bind.setProvider(provider);
+        bind.setProviderUid(providerUid);
+        bind.setCreateTime(new Date());
+        infoUserOauthMapper.insert(bind);
+
+        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        return issueOauthToken(user, provider);
+    }
+
+    /** 三方登录签发 token(claim provider=本次登录渠道),复用现有 JwtUtil + Redis 会话。 */
+    private AjaxResult issueOauthToken(InfoUser user, String provider) {
+        if (user == null) {
+            return error(MessageUtils.message("no.user.not.exist"));
+        }
+        redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + user.getUserId() + ":" + "*");
+        LoginUserDto dto = new LoginUserDto();
+        dto.setUserId(user.getUserId());
+        dto.setUserName(user.getPhone());
+        dto.setProvider(provider);
+        fillLoginUserInfo(dto);
+        String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+        return success(MessageUtils.message("no.user.login.success"), user, token);
+    }
 
     /**
      * 查询用户信息列表

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 三方登录第二步入参:手机号验证 + 绑定(017-oauth-login)。
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Data
+public class OAuthBindDto {
+
+    /** oauthLogin 返回的临时凭证 */
+    private String tempKey;
+
+    /** 手机号 */
+    private String phone;
+
+    /** 短信验证码 */
+    private String code;
+
+    /** 推送字段(与现有登录一致) */
+    private String cid;
+    private String cidType;
+    private String deviceToken;
+    private String voIPToken;
+}

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

@@ -0,0 +1,25 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 三方登录第一步入参(017-oauth-login)。
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Data
+public class OAuthLoginDto {
+
+    /** 三方渠道 apple/google/line */
+    private String provider;
+
+    /** 凭证:Apple identityToken / Google idToken / LINE accessToken */
+    private String credential;
+
+    /** 推送字段(与现有登录一致) */
+    private String cid;
+    private String cidType;
+    private String deviceToken;
+    private String voIPToken;
+}

+ 188 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java

@@ -0,0 +1,188 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.ECDSAVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.Date;
+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>Google:tokeninfo HTTP 验真 + 校 audience → sub</li>
+ *   <li>LINE:v2/profile HTTP(Bearer)→ userId</li>
+ * </ul>
+ * <p>注:Google 走 tokeninfo 而非 firebase-admin,避免 FirebaseApp+服务账号初始化;各家真实 token + clientId 需联调验证。</p>
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Service
+public class OAuthVerifyService {
+
+    @Value("${oauth.apple.client-id}")
+    private String appleClientId;
+    @Value("${oauth.apple.jwks-url}")
+    private String appleJwksUrl;
+    @Value("${oauth.google.client-id}")
+    private String googleClientId;
+    @Value("${oauth.google.tokeninfo-url}")
+    private String googleTokeninfoUrl;
+    @Value("${oauth.line.profile-url}")
+    private String lineProfileUrl;
+
+    /**
+     * 校验 provider 凭证,返回稳定的 providerUid。
+     *
+     * @param provider   apple/google/line
+     * @param credential Apple identityToken / Google idToken / LINE accessToken
+     */
+    public String verify(String provider, String credential) {
+        if (provider == null || provider.isEmpty()) {
+            throw new ServiceException(MessageUtils.message("no.oauth.provider.blank"));
+        }
+        if (credential == null || credential.isEmpty()) {
+            throw new ServiceException(MessageUtils.message("no.oauth.credential.blank"));
+        }
+        switch (provider) {
+            case "apple":
+                return verifyApple(credential);
+            case "google":
+                return verifyGoogle(credential);
+            case "line":
+                return verifyLine(credential);
+            default:
+                throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
+        }
+    }
+
+    /** Apple:验 ES256 identityToken,返回 sub(不使用邮箱信息)。 */
+    private String verifyApple(String idToken) {
+        try {
+            SignedJWT jwt = SignedJWT.parse(idToken);
+            String kid = jwt.getHeader().getKeyID();
+            // 拉取 Apple 公钥(JWKS)并按 kid 匹配
+            JWKSet jwkSet = JWKSet.load(new URL(appleJwksUrl));
+            JWK jwk = jwkSet.getKeyByKeyId(kid);
+            if (jwk == null) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            // Apple identityToken 实测 alg=RS256(RSA);按 JWK 类型选验签器,兼容 EC
+            JWSVerifier verifier;
+            if (jwk instanceof RSAKey) {
+                verifier = new RSASSAVerifier(((RSAKey) jwk).toRSAPublicKey());
+            } else if (jwk instanceof ECKey) {
+                verifier = new ECDSAVerifier(((ECKey) jwk).toECPublicKey());
+            } else {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            if (!jwt.verify(verifier)) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            JWTClaimsSet claims = jwt.getJWTClaimsSet();
+            if (!"https://appleid.apple.com".equals(claims.getIssuer())) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            List<String> aud = claims.getAudience();
+            if (aud == null || !aud.contains(appleClientId)) {
+                throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Apple"));
+            }
+            Date exp = claims.getExpirationTime();
+            if (exp == null || exp.before(new Date())) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.expired", "Apple"));
+            }
+            String sub = claims.getSubject();
+            if (sub == null || sub.isEmpty()) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            return sub;
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple", e.getMessage()));
+        }
+    }
+
+    /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
+    private String verifyGoogle(String idToken) {
+        try {
+            String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
+            JSONObject json = JSONObject.parseObject(httpGet(url, null));
+            if (json == null || json.containsKey("error") || json.containsKey("error_description")) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
+            }
+            String aud = json.getString("aud");
+            if (!googleClientId.equals(aud)) {
+                throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Google"));
+            }
+            String sub = json.getString("sub");
+            if (sub == null || sub.isEmpty()) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
+            }
+            return sub;
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google", e.getMessage()));
+        }
+    }
+
+    /** LINE:v2/profile HTTP(Bearer accessToken),返回 userId。 */
+    private String verifyLine(String accessToken) {
+        try {
+            JSONObject json = JSONObject.parseObject(httpGet(lineProfileUrl, accessToken));
+            if (json == null) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
+            }
+            String userId = json.getString("userId");
+            if (userId == null || userId.isEmpty()) {
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
+            }
+            return userId;
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "LINE", e.getMessage()));
+        }
+    }
+
+    /** 简单 GET;bearer 非空时带 Authorization 头(LINE 用)。带超时,防止 provider 慢/不可达拖垮线程。 */
+    private String httpGet(String url, String bearer) throws Exception {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpGet get = new HttpGet(url);
+            get.setConfig(RequestConfig.custom()
+                    .setConnectTimeout(5000)
+                    .setSocketTimeout(10000)
+                    .setConnectionRequestTimeout(5000)
+                    .build());
+            if (bearer != null && !bearer.isEmpty()) {
+                get.setHeader("Authorization", "Bearer " + bearer);
+            }
+            try (CloseableHttpResponse resp = client.execute(get)) {
+                return EntityUtils.toString(resp.getEntity(), "UTF-8");
+            }
+        }
+    }
+}

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

@@ -46,6 +46,21 @@ im:
   # 连接/读取超时(毫秒)
   timeout-ms: 5000
 
+# 三方登录配置(017-oauth-login)
+oauth:
+  apple:
+    # client_id = Apple client_id(identityToken 的 audience,实测=com.amazewayhk.zhibo)
+    client-id: com.amazewayhk.zhibo
+    # Apple 公钥地址(验签用,一般不改)
+    jwks-url: https://appleid.apple.com/auth/keys
+  google:
+    # Google OAuth client_id(校验 ID-Token 的 audience)
+    client-id: your-google-client-id.apps.googleusercontent.com
+    tokeninfo-url: https://oauth2.googleapis.com/tokeninfo
+  line:
+    # 用 accessToken 换用户信息的端点(一般不改)
+    profile-url: https://api.line.me/v2/profile
+
 # 开发环境配置
 server:
   # 服务器的HTTP端口,默认为8080

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

@@ -168,3 +168,15 @@ no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
 no.posorder.md.cx.jiner.messag=促销抵扣:{};
 no.wallet.common.cs.error=操作失败,请联系客服
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=登录方式不能为空
+no.oauth.credential.blank=三方凭证不能为空
+no.oauth.provider.unsupported=不支持的登录方式:{0}
+no.oauth.needphone=首次登录请验证手机号
+no.oauth.tempkey.missing=登录凭证缺失,请重新登录
+no.oauth.tempkey.expired=登录凭证已过期,请重新登录
+no.oauth.token.invalid={0}登录凭证无效
+no.oauth.token.expired={0}登录凭证已过期
+no.oauth.audience.mismatch={0}凭证校验未通过
+no.oauth.verify.fail={0}登录校验失败:{1}

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

@@ -172,3 +172,15 @@ no.posorder.md.yh.jiner.messag=Discount: {};
 no.posorder.md.cx.mc.messag=Promo: {};
 no.posorder.md.cx.jiner.messag=Promo discount: {};
 no.wallet.common.cs.error=Operation failed, please contact customer service
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=Login method is required
+no.oauth.credential.blank=Third-party credential is required
+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.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}

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

@@ -172,3 +172,15 @@ no.posorder.md.yh.jiner.messag=Giảm giá: {};
 no.posorder.md.cx.mc.messag=Khuyến mãi: {};
 no.posorder.md.cx.jiner.messag=Giảm KM: {};
 no.wallet.common.cs.error=Thất bại, vui lòng liên hệ CSKH
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=Vui lòng chọn cách đăng nhập
+no.oauth.credential.blank=Thông tin xác thực bên thứ ba không được để trống
+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.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}

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

@@ -172,3 +172,15 @@ no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
 no.posorder.md.cx.jiner.messag=促销抵扣:{};
 no.wallet.common.cs.error=操作失败,请联系客服
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=登录方式不能为空
+no.oauth.credential.blank=三方凭证不能为空
+no.oauth.provider.unsupported=不支持的登录方式:{0}
+no.oauth.needphone=首次登录请验证手机号
+no.oauth.tempkey.missing=登录凭证缺失,请重新登录
+no.oauth.tempkey.expired=登录凭证已过期,请重新登录
+no.oauth.token.invalid={0}登录凭证无效
+no.oauth.token.expired={0}登录凭证已过期
+no.oauth.audience.mismatch={0}凭证校验未通过
+no.oauth.verify.fail={0}登录校验失败:{1}

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

@@ -172,3 +172,15 @@ no.posorder.md.yh.jiner.messag=優惠抵扣:{};
 no.posorder.md.cx.mc.messag=促銷:{};
 no.posorder.md.cx.jiner.messag=促銷抵扣:{};
 no.wallet.common.cs.error=操作失敗,請聯繫客服
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=登入方式不能為空
+no.oauth.credential.blank=第三方憑證不能為空
+no.oauth.provider.unsupported=不支援的登入方式:{0}
+no.oauth.needphone=首次登入請驗證手機號
+no.oauth.tempkey.missing=登入憑證缺失,請重新登入
+no.oauth.tempkey.expired=登入憑證已過期,請重新登入
+no.oauth.token.invalid={0}登入憑證無效
+no.oauth.token.expired={0}登入憑證已過期
+no.oauth.audience.mismatch={0}憑證校驗未通過
+no.oauth.verify.fail={0}登入校驗失敗:{1}

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

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

+ 41 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserOauth.java

@@ -0,0 +1,41 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 三方账号绑定 info_user_oauth。
+ * <p>记录 InfoUser 与 Apple/Google/LINE 的绑定关系。手机号始终是 InfoUser 的主键,
+ * 三方账号只是快捷登录入口。按 (provider, provider_uid) 反查用户(唯一索引)。</p>
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Data
+@TableName("info_user_oauth")
+public class InfoUserOauth {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 info_user.user_id */
+    private Long userId;
+
+    /** 三方渠道 apple/google/line */
+    private String provider;
+
+    /** 三方稳定用户ID(Apple sub / Google sub / LINE userId) */
+    private String providerUid;
+
+    /** 绑定时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthMapper.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoUserOauth;
+
+/**
+ * 三方账号绑定 Mapper(仅用 MyBatis-Plus CRUD,无自定义查询、无 XML)。
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+public interface InfoUserOauthMapper extends BaseMapper<InfoUserOauth> {
+}

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

@@ -63,6 +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(三方登录标记)
                     .withJWTId(jti) //jwt的唯一标识符,每个token都有唯一的JTI,用于防止重放攻击和token撤销
                     .sign(algorithm);
             redisCache.setCacheObject(jti, user, (int)EXPIRE_DATE, TimeUnit.MILLISECONDS);

+ 95 - 0
specs/017-oauth-login/plan.md

@@ -0,0 +1,95 @@
+# 017 实施计划
+
+## 架构:最大化复用现有登录链路
+
+```
+provider 凭证 ──▶ OAuthVerifyService.verify(provider, credential) ──▶ providerUid
+                                                                        │
+                ┌───────────────────────────────────────────────────────┘
+                ▼
+        查 info_user_oauth (provider, provider_uid)
+        ├─ 命中 → 取 InfoUser → 校 status/del_flag → issueToken(provider) → 返回
+        └─ 未命中 → Redis 缓存 {provider,providerUid}(tempKey,TTL5m) → 返回 needPhone+tempKey
+                                                                        │
+                ┌───────────────────────────────────────────────────────┘
+                ▼  (前端引导输手机号 → getcode 发短信)
+        oauthBindPhone(tempKey, phone, code)
+          取 providerUid + 验短信(复用 lodeing: redis code==input || "8888")
+          getuser(phone):
+            ├─ 已注册 → 关联(写 oauth)
+            └─ 未注册 → createUser(phone) 新建 → 写 oauth
+          → issueToken(provider) → 返回
+```
+
+- **token 签发**:复用 `JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, loginDto)`,仅给 `LoginUserDto` 加 `provider` 字段并在 setToken 写入 claim。
+- **新建用户**:复用 `createUser` 的「建 InfoUser + 建钱包 + 删旧 token」逻辑;昵称=手机号不变。
+- **短信**:复用 `getcode` 发送 + `lodeing` 的校验(key=phone 去+,万能码 8888)。
+
+## 数据模型
+
+```sql
+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_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),
+  KEY idx_user_id (user_id)
+) COMMENT='三方账号绑定';
+```
+
+## API 契约
+
+### 1) `POST /infouser/user/oauthLogin`  (`@Anonymous`)
+请求 `OAuthLoginDto`:
+```
+provider     apple|google|line
+credential   identityToken(Apple) / idToken(Google) / accessToken(LINE)
+cid, cidType, deviceToken, voIPToken   // 推送字段,与现有登录一致
+```
+响应(命中):
+```
+{ code:200, msg, data: InfoUser, token }   // token claim provider=本次渠道
+```
+响应(未命中,首次):
+```
+{ code:200, msg, data:{ status:"needPhone", tempKey } }
+```
+
+### 2) `POST /infouser/user/oauthBindPhone`  (`@Anonymous`)
+请求 `OAuthBindDto`:
+```
+tempKey, phone, code   // code=短信验证码
+cid, cidType, deviceToken, voIPToken
+```
+响应:`{ code:200, msg, data: InfoUser, token }`
+
+## 三家凭证校验(OAuthVerifyService)
+
+| provider | 凭证 | 校验 | 取 providerUid | 依赖 |
+|---|---|---|---|---|
+| 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(已有) |
+
+> Google 走 tokeninfo HTTP 而非 firebase-admin,因后者需初始化 FirebaseApp+服务账号(项目未配置),tokeninfo 零配置即可。
+
+## 文件清单
+
+| 类型 | 文件 |
+|---|---|
+| 新增实体 | `ruoyi-system/.../domain/InfoUserOauth.java` |
+| 新增 mapper | `ruoyi-system/.../mapper/InfoUserOauthMapper.java`(BaseMapper,无 XML) |
+| 新增校验服务 | `ruoyi-admin/.../utils/oauth/OAuthVerifyService.java` |
+| 新增 DTO | `ruoyi-admin/.../user/dto/OAuthLoginDto.java`、`OAuthBindDto.java` |
+| 改 controller | `ruoyi-admin/.../user/InfoUserController.java`(+2 方法) |
+| 改 token | `ruoyi-common/.../model/LoginUserDto.java`、`ruoyi-system/.../utils/JwtUtil.java` |
+| 改配置 | `ruoyi-admin/src/main/resources/application.yml`(oauth 段) |
+| 改依赖 | `ruoyi-admin/pom.xml`(+nimbus-jose-jwt) |
+| SQL | `updatesql/sql.md` |
+
+## 风险 / 待验证
+
+- Apple / Google / LINE 的凭证校验**需用各家真实 token + 正确 clientId/bundleId 联调**才能端到端验证;配置值(clientId/jwks url)由前端/运营提供后填入 application.yml。
+- Apple JWKS 建议加缓存(key 偶尔轮换);初版每次拉取或加内存缓存。

+ 51 - 0
specs/017-oauth-login/spec.md

@@ -0,0 +1,51 @@
+# 017 - Apple / Google / LINE 三方登录
+
+## 背景
+
+C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/lodeing`,登录即注册)。本特性新增 Apple / Google / LINE 三方登录,降低台湾市场获客与登录摩擦。鉴权沿用现有自写 `JwtUtil`(HS256 JWT + Redis 会话),不引入新鉴权体系。
+
+## 锁定的关键决策(业务确认)
+
+1. **手机号始终是主键**:三方账号只是绑在「某个手机号账号」上的快捷入口,不在 InfoUser 上落三方 ID(独立表 `info_user_oauth` 记录绑定关系)。
+2. **首次使用任一三方登录强制手机验证**:拿到手机号后查 `info_user`——已注册则**关联**到该老账号(写一条 oauth 绑定),未注册则**新建**账号后再绑定。**不以邮箱做关联**。
+3. **不使用 Apple 邮箱信息**:Apple 只取 identityToken 的 `sub`(稳定用户ID),忽略 email。
+4. **记录登录渠道 loginType**:因一个用户可绑多家三方,loginType 不落用户表,而写入**本次会话的 JWT claim**(`provider`)。
+
+## User Story
+
+### US1 - 三方登录入口 (P0)
+客户在登录页选 Apple/Google/LINE,客户端拿到 provider 凭证后调后端校验。
+
+### US2 - 首次登录手机验证 + 绑定 (P0)
+首次用某三方账号登录时,强制输手机号 + 短信验证码:手机号已注册→关联该账号;未注册→新建账号;随后建立 oauth 绑定。
+
+### US3 - 已绑定快捷登录 (P0)
+已绑过该三方的账号再次登录,校验凭证通过即直接签发 token,免手机验证。
+
+## Functional Requirements
+
+- **FR-001**: 提供 `POST /infouser/user/oauthLogin {provider, credential, ...}`,后端校验 provider 凭证换取稳定 providerUid。
+- **FR-002**: 凭证校验三选一:Apple=验 ES256 identityToken 取 sub;Google=tokeninfo HTTP 验真取 sub 并校 audience;LINE=v2/profile HTTP 取 userId。
+- **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-007**: 登录端点 `@Anonymous` 放行;受保护接口继续走 `@Auth`,零额外接入。
+- **FR-008**: 凭证校验失败 / tempKey 过期 / 短信码错误 → 明确错误提示,不签发 token、不建账号。
+
+## Key Entities
+
+- **info_user_oauth(新增)**:`id`、`user_id`、`provider`(apple/google/line)、`provider_uid`、`create_time`。`UNIQUE(provider, provider_uid)` 用于按三方ID反查用户;`user_id` 普通索引。
+- **InfoUser(已有,不改)**:仍是手机号为主键,无三方 ID 列。
+
+## 安全要点
+
+- Apple/Google 必须校验 audience(= 我方 client_id/bundleId),防其他 App 的 token 被冒用。
+- Apple 校验 iss=`https://appleid.apple.com` + exp。
+- tempKey 一次性、短 TTL(5 分钟),防重放。
+
+## 不在本期范围
+
+- 后台管理端解绑/查看三方绑定(后续增量)。
+- 第三家以上三方(微信/Facebook)——表结构已可扩展,但本期只接 Apple/Google/LINE。
+- Apple 隐藏邮箱中转、refresh_token 续期等高级场景。

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

@@ -0,0 +1,32 @@
+# 017 任务清单
+
+> 顺序执行;每步完成后编译验证。
+
+- [ ] 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。
+
+## 联调待办(开发提供配置后)
+- [ ] application.yml 填真实 apple bundleId/clientId、google clientId、line channel。
+- [ ] 三家真实 token 端到端验证(Apple 需真机/测试机签 Sign in with Apple)。
+- [ ] 前端 app:登录页三按钮 + 首次绑手机流程 + i18n。
+
+## 代码评审修复 (2026-07-30)
+
+评审发现并修复(均已 JDK21 编译通过):
+
+- [x] **HTTP 超时**:`OAuthVerifyService.httpGet` 原用 `HttpClients.createDefault()` 无超时,provider 慢/不可达会拖垮请求线程池 → 加 `RequestConfig`(连接 5s / 读取 10s / 获取连接 5s)。
+- [x] **停用账号拦截**:`oauthBindPhone` 用 `getuser(phone)` 只滤 del_flag、不滤 status,停用账号(status≠0)能通过三方绑定绕过停用 → 加 `status!=0` 拦截(与 `lodeing` / `oauthLogin` 已绑定路径一致),文案复用 `no.user.stop`。
+- [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}` 异常详情)

+ 15 - 0
updatesql/sql.md

@@ -447,3 +447,18 @@ ALTER TABLE pos_order_invoice
 ALTER TABLE info_invoice
   ADD COLUMN is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头 0否 1是(同一用户仅一条为1)';
 ```
+
+
+## 2026-07-30 新增三方登录绑定表(017-oauth-login)
+
+```sql
+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_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),
+  KEY idx_user_id (user_id)
+) COMMENT='三方账号绑定(Apple/Google/LINE)';
+```