ソースを参照

feat: 017 第三方名称展示增量后端实施(LINE displayName 刷新/Apple fullName 保存/会话渠道回填)

qmj 3 週間 前
親
コミット
c34e3bf573

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

@@ -43,6 +43,7 @@ import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.system.domain.InfoUserDevice;
@@ -895,6 +896,26 @@ public class InfoUserController extends BaseController {
             } else {
                 user.setPointsWallet(Long.valueOf(userWallet.getPointsWallet().toString()));
             }
+            // 017 2026-09-24 增量:按本次登录会话回填渠道与第三方名称。
+            // 渠道来自会话 claim;名称精确按 userId+providerUid 反查绑定,无 providerUid 的旧会话只展示渠道,不任意挑绑定。
+            Map<String, Object> sessionClaims = JwtUtil.verifyToken(token);
+            Object providerClaim = sessionClaims.get("provider");
+            String oauthProvider = providerClaim == null || "null".equals(providerClaim)
+                    ? null : String.valueOf(providerClaim);
+            if (oauthProvider != null && !"phone".equals(oauthProvider)) {
+                user.setOauthProvider(oauthProvider);
+                Object uidClaim = sessionClaims.get("providerUid");
+                if (uidClaim != null && !"null".equals(uidClaim) && !String.valueOf(uidClaim).isEmpty()) {
+                    InfoUserOauth identity = infoUserOauthMapper.selectOne(
+                            new LambdaQueryWrapper<InfoUserOauth>()
+                                    .eq(InfoUserOauth::getUserId, Long.valueOf(id))
+                                    .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(oauthProvider))
+                                    .eq(InfoUserOauth::getProviderUid, String.valueOf(uidClaim)));
+                    if (identity != null) {
+                        user.setProviderDisplayName(identity.getProviderDisplayName());
+                    }
+                }
+            }
             return success(user);
         }
     }
@@ -946,8 +967,12 @@ public class InfoUserController extends BaseController {
             LoginUserDto userDto = new LoginUserDto();
             userDto.setUserId(user.getUserId());
             userDto.setUserName(user.getUserName());
-            Object provider = JwtUtil.verifyToken(token).get("provider");
+            Map<String, Object> renewClaims = JwtUtil.verifyToken(token);
+            Object provider = renewClaims.get("provider");
             userDto.setProvider(provider == null || "null".equals(provider) ? "phone" : String.valueOf(provider));
+            Object providerUid = renewClaims.get("providerUid");
+            userDto.setProviderUid(providerUid == null || "null".equals(providerUid)
+                    ? null : String.valueOf(providerUid));
             fillLoginUserInfo(userDto);
             String wtoken = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, userDto);
             return success(MessageUtils.message("no.user.token.success"), wtoken);
@@ -1102,7 +1127,14 @@ public class InfoUserController extends BaseController {
             lineOAuthStateService.consume(dto.getProvider(), dto.getState());
         }
         log.info("[OAuth] oauthLogin provider={}", dto.getProvider());
-        String providerUid = oauthVerifyService.verify(dto.getProvider(), dto.getCredential());
+        OAuthIdentity identity = oauthVerifyService.verifyIdentity(dto.getProvider(), dto.getCredential());
+        String providerUid = identity.providerUid();
+        // 第三方显示名称:LINE 取 profile displayName;Apple 由 App 首次授权提交 fullName(017 2026-09-24 增量)
+        String displayName = identity.displayName();
+        if ((displayName == null || displayName.isEmpty()) && dto.getProvider().startsWith("apple")
+                && dto.getFullName() != null && !dto.getFullName().isBlank()) {
+            displayName = dto.getFullName().trim();
+        }
 
         // 查是否已绑定
         InfoUserOauth bind = infoUserOauthMapper.selectOne(
@@ -1128,15 +1160,19 @@ public class InfoUserController extends BaseController {
             u.setVoIPToken(dto.getVoIPToken());
             infoUserService.saveOrUpdate(u);
             deviceTrustService.recordLogin(dto.getDeviceId(), u.getPhone(), u.getUserId());
-            return issueOauthToken(u, dto.getProvider());
+            refreshOauthDisplayName(bind, displayName);
+            return issueOauthToken(u, dto.getProvider(), providerUid);
         }
 
         // 未绑定:缓存 {provider, providerUid},返回 needPhone + tempKey
         log.info("[OAuth] 未绑定,返回 needPhone provider={}", dto.getProvider());
         // (029:同设备已验证过手机号 → 免短信一键确认 deviceConfirm 分支)
         String tempKey = UUID.randomUUID().toString().replace("-", "");
-        redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
-                dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
+        // 未绑定时把第三方名称一并带进临时凭证,绑定时落库;名称为空保持两段式(兼容旧缓存值)
+        String tempValue = displayName == null || displayName.isEmpty()
+                ? dto.getProvider() + "@" + providerUid
+                : dto.getProvider() + "@" + providerUid + "@" + displayName;
+        redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, tempValue, 5, TimeUnit.MINUTES);
         // 029:设备信任免短信确认仅用户端(会员)适用;业务账号绑定必须真实短信,不走免确认
         String trustedPhone = accountType == null || accountType == OAuthAccountType.USER
                 ? deviceTrustService.getTrustedPhone(dto.getDeviceId()) : null;
@@ -1174,12 +1210,13 @@ public class InfoUserController extends BaseController {
             log.warn("[OAuth] tempKey 已过期/不存在");
             return error(MessageUtils.message("no.oauth.tempkey.expired"));
         }
-        int at = cached.indexOf('@');
-        if (at <= 0 || at == cached.length() - 1) {
+        String[] cachedParts = cached.split("@", 3);
+        if (cachedParts.length < 2 || cachedParts[0].isEmpty() || cachedParts[1].isEmpty()) {
             return error(MessageUtils.message("no.oauth.tempkey.expired"));
         }
-        String provider = cached.substring(0, at);
-        String providerUid = cached.substring(at + 1);
+        String provider = cachedParts[0];
+        String providerUid = cachedParts[1];
+        String displayName = cachedParts.length > 2 ? cachedParts[2] : null;
         OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
         log.info("[OAuth] 绑定流程 provider={}", provider);
 
@@ -1194,7 +1231,8 @@ public class InfoUserController extends BaseController {
             if (accountType != null && !canOauthLogin(accountType, u)) {
                 return error(MessageUtils.message("no.user.stop"));
             }
-            return issueOauthToken(u, provider);
+            refreshOauthDisplayName(exist, displayName);
+            return issueOauthToken(u, provider, providerUid);
         }
 
         // 用户端保留既有校验;业务账号绑定必须通过真实短信验证码。
@@ -1264,6 +1302,7 @@ public class InfoUserController extends BaseController {
         bind.setUserId(user.getUserId());
         bind.setProvider("line".equals(provider) ? "line_user" : provider);
         bind.setProviderUid(providerUid);
+        bind.setProviderDisplayName(displayName != null && !displayName.isBlank() ? displayName : null);
         bind.setCreateTime(new Date());
         if (infoUserOauthMapper.insert(bind) != 1) {
             throw new ServiceException(MessageUtils.message("no.system.error"));
@@ -1272,7 +1311,7 @@ public class InfoUserController extends BaseController {
 
         redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
         deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
-        return issueOauthToken(user, provider);
+        return issueOauthToken(user, provider, providerUid);
     }
 
     /**
@@ -1302,9 +1341,10 @@ public class InfoUserController extends BaseController {
             log.warn("[OAuth] 设备确认凭证与设备不匹配 tempKey={}, deviceId={}", dto.getTempKey(), dto.getDeviceId());
             return error(MessageUtils.message("no.oauth.device.mismatch"));
         }
-        int at = cached.indexOf('@');
-        String provider = cached.substring(0, at);
-        String providerUid = cached.substring(at + 1);
+        String[] cachedParts = cached.split("@", 3);
+        String provider = cachedParts[0];
+        String providerUid = cachedParts[1];
+        String displayName = cachedParts.length > 2 ? cachedParts[2] : null;
         OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
         if (accountType != null && accountType != OAuthAccountType.USER) {
             // 业务账号绑定必须真实短信验证,设备信任免确认不适用
@@ -1324,7 +1364,8 @@ public class InfoUserController extends BaseController {
             if (accountType != null && !canOauthLogin(accountType, u)) {
                 return error(MessageUtils.message("no.user.stop"));
             }
-            return issueOauthToken(u, provider);
+            refreshOauthDisplayName(exist, displayName);
+            return issueOauthToken(u, provider, providerUid);
         }
 
         // 信任手机号即账号主键:已注册校验停用、未注册自动创建(与 oauthBindPhone 一致,防绕过停用)
@@ -1354,6 +1395,7 @@ public class InfoUserController extends BaseController {
         bind.setUserId(user.getUserId());
         bind.setProvider("line".equals(provider) ? "line_user" : provider);
         bind.setProviderUid(providerUid);
+        bind.setProviderDisplayName(displayName != null && !displayName.isBlank() ? displayName : null);
         bind.setCreateTime(new Date());
         infoUserOauthMapper.insert(bind);
         log.info("[OAuth] 设备确认写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
@@ -1361,7 +1403,7 @@ public class InfoUserController extends BaseController {
         redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
         redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
         deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
-        return issueOauthToken(user, provider);
+        return issueOauthToken(user, provider, providerUid);
     }
 
     /** 手机号脱敏:保留前 3 后 4,中间 ****(仅日志用)。 */
@@ -1372,8 +1414,18 @@ public class InfoUserController extends BaseController {
         return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
     }
 
-    /** 三方登录签发 token(claim provider=本次登录渠道),复用现有 JwtUtil + Redis 会话。 */
-    private AjaxResult issueOauthToken(InfoUser user, String provider) {
+    /** 017 2026-09-24 增量:登录成功刷新第三方显示名称;名称为空/未变化保留旧值,失败不阻断登录。 */
+    private void refreshOauthDisplayName(InfoUserOauth bind, String displayName) {
+        if (bind == null || displayName == null || displayName.isBlank()
+                || displayName.equals(bind.getProviderDisplayName())) {
+            return;
+        }
+        bind.setProviderDisplayName(displayName);
+        infoUserOauthMapper.updateById(bind);
+    }
+
+    /** 三方登录签发 token(claim provider=本次登录渠道、providerUid=本次绑定身份),复用现有 JwtUtil + Redis 会话。 */
+    private AjaxResult issueOauthToken(InfoUser user, String provider, String providerUid) {
         if (user == null) {
             return error(MessageUtils.message("no.user.not.exist"));
         }
@@ -1391,6 +1443,7 @@ public class InfoUserController extends BaseController {
         dto.setUserName(accountType == null || accountType.allowCreate()
                 ? user.getPhone() : user.getUserName());
         dto.setProvider(provider);
+        dto.setProviderUid(providerUid);
         fillLoginUserInfo(dto);
         String token = JwtUtil.setToken(tokenKey, dto);
         Object loginUser = accountType == OAuthAccountType.MERCHANT ? merchantLoginView(user) : user;

+ 22 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.user;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.utils.oauth.LineOAuthProperties;
 import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
@@ -107,7 +108,8 @@ public class LineCallbackController extends BaseController {
                 response.sendRedirect(channel.appRedirect() + "?error=no_code" + stateSuffix);
                 return;
             }
-            String providerUid = oauthVerifyService.verify(provider, code);
+            OAuthIdentity identity = oauthVerifyService.verifyIdentity(provider, code);
+            String providerUid = identity.providerUid();
             InfoUserOauth bind = infoUserOauthMapper.selectOne(
                     new LambdaQueryWrapper<InfoUserOauth>()
                             .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
@@ -121,7 +123,8 @@ public class LineCallbackController extends BaseController {
                     response.sendRedirect(channel.appRedirect() + "?error=user_stopped" + stateSuffix);
                     return;
                 }
-                String token = buildOauthToken(user, channel);
+                refreshOauthDisplayName(bind, identity.displayName());
+                String token = buildOauthToken(user, channel, providerUid);
                 // 029:旧用户 App 契约 state 由前端传 deviceId(specs/029 §5),登录即记录设备信任;
                 // 业务线 state 为 LINE 授权状态,不是 deviceId,不记录
                 if (!businessLine) {
@@ -133,8 +136,11 @@ public class LineCallbackController extends BaseController {
             }
 
             String tempKey = UUID.randomUUID().toString().replace("-", "");
-            redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
-                    provider + "@" + providerUid, 5, TimeUnit.MINUTES);
+            // 未绑定时把 LINE displayName 一并带进临时凭证;名称为空保持两段式(与 InfoUserController 一致)
+            String tempValue = identity.displayName() == null || identity.displayName().isEmpty()
+                    ? provider + "@" + providerUid
+                    : provider + "@" + providerUid + "@" + identity.displayName();
+            redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, tempValue, 5, TimeUnit.MINUTES);
             // 029:旧用户 App 未绑定且设备已信任 → deviceConfirm 免短信分支;业务线不适用
             String trustedPhone = businessLine ? null : deviceTrustService.getTrustedPhone(state);
             if (trustedPhone != null) {
@@ -171,12 +177,23 @@ public class LineCallbackController extends BaseController {
         }
     }
 
-    private String buildOauthToken(InfoUser user, LineOAuthProperties.ResolvedChannel channel) {
+    /** 017 2026-09-24 增量:登录成功刷新第三方显示名称;名称为空/未变化保留旧值,失败不阻断登录。 */
+    private void refreshOauthDisplayName(InfoUserOauth bind, String displayName) {
+        if (bind == null || displayName == null || displayName.isBlank()
+                || displayName.equals(bind.getProviderDisplayName())) {
+            return;
+        }
+        bind.setProviderDisplayName(displayName);
+        infoUserOauthMapper.updateById(bind);
+    }
+
+    private String buildOauthToken(InfoUser user, LineOAuthProperties.ResolvedChannel channel, String providerUid) {
         redisCache.deleteKeys(channel.tokenKey() + user.getUserId() + ":*");
         LoginUserDto dto = new LoginUserDto();
         dto.setUserId(user.getUserId());
         dto.setUserName(channel.allowCreate() ? user.getPhone() : user.getUserName());
         dto.setProvider(channel.provider());
+        dto.setProviderUid(providerUid);
         fillLoginUserInfo(dto);
         return JwtUtil.setToken(channel.tokenKey(), dto);
     }

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

@@ -28,4 +28,7 @@ public class OAuthLoginDto {
 
     /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
     private String deviceId;
+
+    /** Apple 首次授权共享的用户姓名(App 从 ASAuthorization 凭证取得,仅首次授权有值,可选;017 2026-09-24 增量) */
+    private String fullName;
 }

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthIdentity.java

@@ -0,0 +1,13 @@
+package com.ruoyi.app.utils.oauth;
+
+/**
+ * 三方凭证校验结果(017-oauth-login 2026-09-24 增量)。
+ * <p>providerUid 为稳定三方用户ID(Apple/Google sub、LINE userId),用于绑定反查;
+ * displayName 为本次资料里取得的第三方显示名称——LINE 为 v2/profile 的 displayName,
+ * 可能为 null(资料未带名称或无 profile 权限),为 null 时调用方保留已保存的旧值。</p>
+ *
+ * @author foodie
+ * @date 2026-09-28
+ */
+public record OAuthIdentity(String providerUid, String displayName) {
+}

+ 18 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java

@@ -78,6 +78,14 @@ public class OAuthVerifyService {
      * @param credential Apple identityToken / Google idToken / LINE authorization code
      */
     public String verify(String provider, String credential) {
+        return verifyIdentity(provider, credential).providerUid();
+    }
+
+    /**
+     * 校验 provider 凭证,返回 providerUid 与本次取得的第三方显示名称(017 2026-09-24 增量)。
+     * displayName 仅 LINE 从 profile 读取,可能为 null;Apple 的 fullName 由 App 首次授权另行提交。
+     */
+    public OAuthIdentity verifyIdentity(String provider, String credential) {
         if (provider == null || provider.isEmpty()) {
             throw new ServiceException(MessageUtils.message("no.oauth.provider.blank"));
         }
@@ -87,17 +95,17 @@ public class OAuthVerifyService {
         log.info("[OAuth] 校验凭证 provider={}", provider);
         switch (provider) {
             case "apple":
-                return verifyApple(credential, appleClientId);
+                return new OAuthIdentity(verifyApple(credential, appleClientId), null);
             case "apple_rider":
-                return verifyApple(credential, requireClientId(appleRiderClientId, "Apple"));
+                return new OAuthIdentity(verifyApple(credential, requireClientId(appleRiderClientId, "Apple")), null);
             case "apple_merchant":
-                return verifyApple(credential, requireClientId(appleMerchantClientId, "Apple"));
+                return new OAuthIdentity(verifyApple(credential, requireClientId(appleMerchantClientId, "Apple")), null);
             case "google":
-                return verifyGoogle(credential, googleClientId, false);
+                return new OAuthIdentity(verifyGoogle(credential, googleClientId, false), null);
             case "google_rider":
-                return verifyGoogle(credential, requireClientId(googleRiderClientId, "Google"), true);
+                return new OAuthIdentity(verifyGoogle(credential, requireClientId(googleRiderClientId, "Google"), true), null);
             case "google_merchant":
-                return verifyGoogle(credential, requireClientId(googleMerchantClientId, "Google"), true);
+                return new OAuthIdentity(verifyGoogle(credential, requireClientId(googleMerchantClientId, "Google"), true), null);
             case "line":
             case "line_user":
             case "line_rider":
@@ -209,11 +217,12 @@ public class OAuthVerifyService {
      * <p>标准 LINE Login 流程:前端拿到授权 code 后传给后端,后端用 code + clientSecret + clientId
      * 向 {@code https://api.line.me/oauth2/v2.1/token} 换 access_token(form-urlencoded),
      * 再用 access_token 调 v2/profile 取稳定的 userId 作为 providerUid。
+     * displayName 从 profile 读取,可能为 null(资料未带名称时调用方保留旧值,017 2026-09-24 增量)。
      *
      * @param code LINE 授权码(前端授权后获得,一次性)
-     * @return LINE userId
+     * @return LINE userId + displayName
      */
-    private String verifyLine(String provider, String code) {
+    private OAuthIdentity verifyLine(String provider, String code) {
         try {
             LineOAuthProperties.ResolvedChannel channel = lineOAuthProperties.requireChannel(provider);
             log.info("[OAuth][LINE] 换 token 请求 provider={}, client_id={}, redirect_uri={}",
@@ -242,7 +251,7 @@ public class OAuthVerifyService {
                 throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
             }
             log.info("[OAuth][LINE] 校验通过 provider={}", provider);
-            return userId;
+            return new OAuthIdentity(userId, profile.getString("displayName"));
         } catch (ServiceException se) {
             throw se;
         } catch (Exception e) {

+ 5 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.user;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.system.domain.vo.UserDTO;
 import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -141,7 +142,7 @@ class DeviceTrustFlowTest {
 
     @Test
     void oauthLoginUnboundWithTrustedDeviceReturnsDeviceConfirmAndWritesBothKeys() {
-        when(oauthVerifyService.verify("google", "cred-1")).thenReturn("uid-1");
+        when(oauthVerifyService.verifyIdentity("google", "cred-1")).thenReturn(new OAuthIdentity("uid-1", null));
         when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
         when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
 
@@ -163,7 +164,7 @@ class DeviceTrustFlowTest {
 
     @Test
     void oauthLoginUnboundWithoutDeviceIdStillReturnsNeedPhone() {
-        when(oauthVerifyService.verify("google", "cred-1")).thenReturn("uid-1");
+        when(oauthVerifyService.verifyIdentity("google", "cred-1")).thenReturn(new OAuthIdentity("uid-1", null));
         when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
 
         OAuthLoginDto dto = new OAuthLoginDto();
@@ -180,7 +181,7 @@ class DeviceTrustFlowTest {
 
     @Test
     void oauthLoginUnboundWithUnknownDeviceStillReturnsNeedPhone() {
-        when(oauthVerifyService.verify("apple", "cred-2")).thenReturn("uid-2");
+        when(oauthVerifyService.verifyIdentity("apple", "cred-2")).thenReturn(new OAuthIdentity("uid-2", null));
         when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
         when(deviceMapper.selectById("dev-x")).thenReturn(null);
 
@@ -374,7 +375,7 @@ class DeviceTrustFlowTest {
 
     @Test
     void oauthLoginBoundDirectLoginRecordsDeviceTrust() {
-        when(oauthVerifyService.verify("line", "cred-3")).thenReturn("uid-3");
+        when(oauthVerifyService.verifyIdentity("line", "cred-3")).thenReturn(new OAuthIdentity("uid-3", null));
         InfoUserOauth bind = new InfoUserOauth();
         bind.setUserId(100L);
         bind.setProvider("line");

+ 188 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -13,7 +13,10 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUserOauth;
@@ -46,10 +49,13 @@ import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.web.context.request.RequestContextHolder;
 import org.springframework.web.context.request.ServletRequestAttributes;
 
+import com.ruoyi.system.service.IVipUserService;
+
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Locale;
+import java.util.concurrent.TimeUnit;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -95,6 +101,7 @@ class InfoUserControllerTest {
         messageSource.addMessage("merchant.subaccount.platform.managed", Locale.getDefault(),
                 "分管账号只能通过专用入口管理");
         messageSource.addMessage("no.oauth.phone.blank", Locale.getDefault(), "手机号不能为空");
+        messageSource.addMessage("no.oauth.needphone", 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(), "账号不存在");
@@ -594,6 +601,7 @@ class InfoUserControllerTest {
         String token = (String) result.get("token");
         assertTrue(JWT.decode(token).getId().startsWith("qtw_tokens:user:"));
         assertEquals(provider, JWT.decode(token).getClaim("provider").asString());
+        assertEquals("provider-uid", JWT.decode(token).getClaim("providerUid").asString());
     }
 
     @ParameterizedTest
@@ -605,7 +613,8 @@ class InfoUserControllerTest {
         OAuthLoginDto request = new OAuthLoginDto();
         request.setProvider(provider);
         request.setCredential("credential");
-        when(oauthVerifyService.verify(provider, "credential")).thenReturn("provider-uid");
+        when(oauthVerifyService.verifyIdentity(provider, "credential"))
+                .thenReturn(new OAuthIdentity("provider-uid", null));
         InfoUserOauth binding = new InfoUserOauth();
         binding.setUserId(72L);
         when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
@@ -627,6 +636,184 @@ class InfoUserControllerTest {
         }
     }
 
+    // ==================== 017 第三方名称展示增量(2026-09-24)====================
+
+    @Test
+    void boundLineLoginRefreshesDisplayNameFromProfile() {
+        OAuthLoginDto request = new OAuthLoginDto();
+        request.setProvider("line_user");
+        request.setCredential("credential");
+        when(oauthVerifyService.verifyIdentity("line_user", "credential"))
+                .thenReturn(new OAuthIdentity("provider-uid", "LINE-顯示名"));
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setId(9L);
+        binding.setUserId(72L);
+        binding.setProvider("line_user");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser user = activeUser(72L, "0");
+        user.setPhone("0912345678");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult result = controller.oauthLogin(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<InfoUserOauth> refreshed = ArgumentCaptor.forClass(InfoUserOauth.class);
+        verify(infoUserOauthMapper).updateById(refreshed.capture());
+        assertEquals(9L, refreshed.getValue().getId());
+        assertEquals("LINE-顯示名", refreshed.getValue().getProviderDisplayName());
+        assertEquals("provider-uid", JWT.decode((String) result.get("token")).getClaim("providerUid").asString());
+    }
+
+    @Test
+    void boundLineLoginKeepsOldDisplayNameWhenProfileNameMissing() {
+        OAuthLoginDto request = new OAuthLoginDto();
+        request.setProvider("line_user");
+        request.setCredential("credential");
+        when(oauthVerifyService.verifyIdentity("line_user", "credential"))
+                .thenReturn(new OAuthIdentity("provider-uid", null));
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setUserId(72L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser user = activeUser(72L, "0");
+        user.setPhone("0912345678");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult result = controller.oauthLogin(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        verify(infoUserOauthMapper, never()).updateById(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void appleFirstAuthorizationSavesFullNameIntoNewBinding() {
+        OAuthLoginDto login = new OAuthLoginDto();
+        login.setProvider("apple");
+        login.setCredential("credential");
+        login.setFullName("王小明");
+        when(oauthVerifyService.verifyIdentity("apple", "credential"))
+                .thenReturn(new OAuthIdentity("provider-uid", null));
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+
+        AjaxResult loginResult = controller.oauthLogin(login);
+
+        assertEquals(HttpStatus.SUCCESS, loginResult.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<String> cacheKey = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<Object> cacheValue = ArgumentCaptor.forClass(Object.class);
+        verify(redisCache).setCacheObject(cacheKey.capture(), cacheValue.capture(),
+                org.mockito.ArgumentMatchers.eq(5), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+        assertEquals("apple@provider-uid@王小明", cacheValue.getValue());
+
+        when(redisCache.getCacheObject(cacheKey.getValue())).thenReturn((String) cacheValue.getValue());
+        when(redisCache.deleteObject(cacheKey.getValue())).thenReturn(true);
+        OAuthBindDto bind = new OAuthBindDto();
+        bind.setTempKey(cacheKey.getValue().substring("oauth:bind:".length()));
+        bind.setPhone("0912345678");
+        bind.setCode("8888");
+        InfoUser user = activeUser(72L, "0");
+        user.setPhone("0912345678");
+        when(infoUserService.list(any(Wrapper.class)))
+                .thenReturn(java.util.List.of(), java.util.List.of(user));
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult bindResult = controller.oauthBindPhone(bind);
+
+        assertEquals(HttpStatus.SUCCESS, bindResult.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<InfoUserOauth> inserted = ArgumentCaptor.forClass(InfoUserOauth.class);
+        verify(infoUserOauthMapper).insert(inserted.capture());
+        assertEquals("apple", inserted.getValue().getProvider());
+        assertEquals("王小明", inserted.getValue().getProviderDisplayName());
+    }
+
+    @Test
+    void legacyTempKeyWithoutThirdPartyNameBindsWithNullDisplayName() {
+        OAuthBindDto request = oauthRequest("apple");
+        InfoUser user = activeUser(72L, "0");
+        user.setPhone(request.getPhone());
+        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(user));
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<InfoUserOauth> inserted = ArgumentCaptor.forClass(InfoUserOauth.class);
+        verify(infoUserOauthMapper).insert(inserted.capture());
+        assertNull(inserted.getValue().getProviderDisplayName());
+    }
+
+    @Test
+    void getuserinfoReturnsSessionChannelAndThirdPartyName() {
+        LoginUserDto session = new LoginUserDto();
+        session.setUserId(72L);
+        session.setUserName("0912345678");
+        session.setProvider("apple");
+        session.setProviderUid("uid-A");
+        String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, session);
+        ReflectionTestUtils.setField(controller, "vipUserService", mock(IVipUserService.class));
+        InfoUser user = activeUser(72L, "0");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        when(userWalletService.selectByUserId(72L)).thenReturn(null);
+        InfoUserOauth binding = new InfoUserOauth();
+        binding.setProviderDisplayName("AppleName");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+
+        AjaxResult result = controller.getuserinfo(token);
+
+        InfoUser data = (InfoUser) result.get(AjaxResult.DATA_TAG);
+        assertEquals("apple", data.getOauthProvider());
+        assertEquals("AppleName", data.getProviderDisplayName());
+        // 精确按会话身份(userId + providerUid)反查,不任意取同渠道其它绑定,也不串其它账号
+        ArgumentCaptor<LambdaQueryWrapper<InfoUserOauth>> query =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(infoUserOauthMapper).selectOne(query.capture());
+        query.getValue().getSqlSegment();
+        Collection<Object> params = query.getValue().getParamNameValuePairs().values();
+        assertTrue(params.contains(72L));
+        assertTrue(params.contains("uid-A"));
+        assertTrue(params.contains("apple"));
+    }
+
+    @Test
+    void getuserinfoLeavesChannelEmptyForPhoneSession() {
+        LoginUserDto session = new LoginUserDto();
+        session.setUserId(72L);
+        session.setUserName("0912345678");
+        String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, session);
+        ReflectionTestUtils.setField(controller, "vipUserService", mock(IVipUserService.class));
+        InfoUser user = activeUser(72L, "0");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        when(userWalletService.selectByUserId(72L)).thenReturn(null);
+
+        AjaxResult result = controller.getuserinfo(token);
+
+        InfoUser data = (InfoUser) result.get(AjaxResult.DATA_TAG);
+        assertNull(data.getOauthProvider());
+        assertNull(data.getProviderDisplayName());
+        verify(infoUserOauthMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void getuserinfoWithoutSessionIdentityDoesNotGuessBinding() {
+        LoginUserDto session = new LoginUserDto();
+        session.setUserId(72L);
+        session.setUserName("0912345678");
+        session.setProvider("apple");
+        String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, session);
+        ReflectionTestUtils.setField(controller, "vipUserService", mock(IVipUserService.class));
+        InfoUser user = activeUser(72L, "0");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        when(userWalletService.selectByUserId(72L)).thenReturn(null);
+
+        AjaxResult result = controller.getuserinfo(token);
+
+        InfoUser data = (InfoUser) result.get(AjaxResult.DATA_TAG);
+        // 旧会话无 providerUid:只展示渠道,不按 user_id/provider 任意挑一条绑定
+        assertEquals("apple", data.getOauthProvider());
+        assertNull(data.getProviderDisplayName());
+        verify(infoUserOauthMapper, never()).selectOne(any());
+    }
+
     @ParameterizedTest
     @CsvSource({"apple", "google"})
     void userFirstBindingStillCreatesAUserAndWallet(String provider) {

+ 30 - 6
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.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.common.constant.CacheConstants;
@@ -19,6 +20,7 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.DefaultListableBeanFactory;
 import org.springframework.mock.web.MockHttpServletRequest;
@@ -73,7 +75,7 @@ class LineCallbackControllerTest {
         rider.setRedirectUri("https://api.test/auth/line/callback?provider=line_rider");
         rider.setAppRedirect("com.twanmsdqs.app://pages/UserCenter/oauthLogin");
         properties.setRider(rider);
-        when(verifyService.verify("line_rider", "code")).thenReturn("line-uid");
+        when(verifyService.verifyIdentity("line_rider", "code")).thenReturn(new OAuthIdentity("line-uid", null));
         when(oauthMapper.selectOne(any())).thenReturn(null);
         LineCallbackController controller = new LineCallbackController();
         ReflectionTestUtils.setField(controller, "lineOAuthStateService", mock(LineOAuthStateService.class));
@@ -107,6 +109,28 @@ class LineCallbackControllerTest {
         verify(fixture.redisCache).deleteKeys(CacheConstants.QS_TOKEN_KEY + "42:*");
     }
 
+    @Test
+    void boundCallbackRefreshesDisplayNameFromProfile() throws Exception {
+        CallbackFixture fixture = boundFixture("2");
+        OAuthVerifyService verifier =
+                (OAuthVerifyService) ReflectionTestUtils.getField(fixture.controller, "oauthVerifyService");
+        when(verifier.verifyIdentity("line_rider", "code"))
+                .thenReturn(new OAuthIdentity("line-uid", "LINE-新名字"));
+
+        fixture.controller.callback("line_rider", "code", "state", fixture.response);
+
+        InfoUserOauthMapper oauthMapper =
+                (InfoUserOauthMapper) ReflectionTestUtils.getField(fixture.controller, "infoUserOauthMapper");
+        ArgumentCaptor<InfoUserOauth> refreshed = ArgumentCaptor.forClass(InfoUserOauth.class);
+        verify(oauthMapper).updateById(refreshed.capture());
+        assertEquals("LINE-新名字", refreshed.getValue().getProviderDisplayName());
+        String redirected = fixture.response.getRedirectedUrl();
+        String token = URLDecoder.decode(
+                redirected.substring(redirected.indexOf("?token=") + 7).split("&", 2)[0],
+                StandardCharsets.UTF_8);
+        assertEquals("line-uid", JWT.decode(token).getClaim("providerUid").asString());
+    }
+
     @Test
     void riderChannelRejectsBindingToMerchantAccount() throws Exception {
         CallbackFixture fixture = boundFixture("1");
@@ -139,7 +163,7 @@ class LineCallbackControllerTest {
         merchant.setRedirectUri("https://api.test/auth/line/callback?provider=line_merchant");
         merchant.setAppRedirect("com.twanmsdsj.app://pages/UserCenter/oauthLogin");
         properties.setMerchant(merchant);
-        when(verifyService.verify("line_merchant", "code")).thenReturn("line-uid");
+        when(verifyService.verifyIdentity("line_merchant", "code")).thenReturn(new OAuthIdentity("line-uid", null));
         InfoUserOauth binding = new InfoUserOauth();
         binding.setUserId(55L);
         when(oauthMapper.selectOne(any())).thenReturn(binding);
@@ -179,7 +203,7 @@ class LineCallbackControllerTest {
         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");
+        when(verifier.verifyIdentity("line", "old-code")).thenReturn(new OAuthIdentity("old-user-id", null));
         RedisCache redis = mock(RedisCache.class);
         LineCallbackController controller = new LineCallbackController();
         ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
@@ -194,7 +218,7 @@ class LineCallbackControllerTest {
 
         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(verifier).verifyIdentity("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));
     }
@@ -210,7 +234,7 @@ class LineCallbackControllerTest {
         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(verifier, never()).verifyIdentity(any(), any());
         verify(fixture.redisCache, never()).deleteKeys(any(String.class));
     }
 
@@ -244,7 +268,7 @@ class LineCallbackControllerTest {
         user.setUserType(userType);
         user.setStatus("0");
         user.setDelFlag("0");
-        when(verifyService.verify("line_rider", "code")).thenReturn("line-uid");
+        when(verifyService.verifyIdentity("line_rider", "code")).thenReturn(new OAuthIdentity("line-uid", null));
         when(oauthMapper.selectOne(any())).thenReturn(binding);
         when(infoUserService.getOne(any())).thenReturn(user);
 

+ 4 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.user;
 
 import com.ruoyi.app.user.service.DeviceTrustService;
 import com.ruoyi.app.utils.oauth.LineOAuthProperties;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.utils.spring.SpringUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -117,7 +118,7 @@ class LineCallbackDeviceTrustTest {
 
     @Test
     void unboundWithTrustedDeviceRedirectsDeviceConfirmAndWritesBothKeys() throws Exception {
-        when(oauthVerifyService.verify("line", "code-1")).thenReturn("line-uid-1");
+        when(oauthVerifyService.verifyIdentity("line", "code-1")).thenReturn(new OAuthIdentity("line-uid-1", null));
         when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
         when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
 
@@ -135,7 +136,7 @@ class LineCallbackDeviceTrustTest {
 
     @Test
     void unboundWithUnknownStateStillRedirectsNeedPhone() throws Exception {
-        when(oauthVerifyService.verify("line", "code-2")).thenReturn("line-uid-2");
+        when(oauthVerifyService.verifyIdentity("line", "code-2")).thenReturn(new OAuthIdentity("line-uid-2", null));
         when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
         when(deviceMapper.selectById("dev-x")).thenReturn(null);
 
@@ -149,7 +150,7 @@ class LineCallbackDeviceTrustTest {
 
     @Test
     void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust() throws Exception {
-        when(oauthVerifyService.verify("line", "code-3")).thenReturn("line-uid-3");
+        when(oauthVerifyService.verifyIdentity("line", "code-3")).thenReturn(new OAuthIdentity("line-uid-3", null));
         InfoUserOauth bind = new InfoUserOauth();
         bind.setUserId(100L);
         bind.setProvider("line");

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

@@ -36,4 +36,7 @@ public class LoginUserDto {
 
     /** 本次登录渠道 apple/google/line_user/line_rider/line_merchant/phone */
     private String provider;
+
+    /** 本次登录渠道的绑定身份(providerUid,017 2026-09-24 增量:会话精确反查第三方名称用) */
+    private String providerUid;
 }

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java

@@ -237,6 +237,14 @@ public class InfoUser
     @TableField(exist = false)
     private Long pointsWallet;
 
+    /** 本次登录渠道 apple/google/line_user 等(getuserinfo 按会话回填,非库字段;017 2026-09-24 增量) */
+    @TableField(exist = false)
+    private String oauthProvider;
+
+    /** 本次登录渠道的第三方名称(getuserinfo 按会话绑定身份回填,非库字段;017 2026-09-24 增量) */
+    @TableField(exist = false)
+    private String providerDisplayName;
+
     /** 骑手星数 */
     @TableField(exist = false)
     private Double star;

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

@@ -35,6 +35,9 @@ public class InfoUserOauth {
     /** 三方稳定用户ID(Apple sub / Google sub / LINE userId) */
     private String providerUid;
 
+    /** 第三方显示名称(LINE displayName / Apple 首次授权 fullName,可空;历史空值允许为空) */
+    private String providerDisplayName;
+
     /** 绑定时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;

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

@@ -64,6 +64,7 @@ public class JwtUtil {
                     .withClaim("id", user.getUserId() != null ? String.valueOf(user.getUserId()) : null) //自定义信息,将Long转换为String
                     .withClaim("userName", user.getUserName())//自定义信息
                     .withClaim("provider", user.getProvider())//登录渠道 apple/google/line_user/line_rider/line_merchant/phone
+                    .withClaim("providerUid", user.getProviderUid())//本次登录的绑定身份(017 2026-09-24 增量,非三方登录为空)
                     .withJWTId(jti) //jwt的唯一标识符,每个token都有唯一的JTI,用于防止重放攻击和token撤销
                     .sign(algorithm);
             redisCache.setCacheObject(jti, user, (int)EXPIRE_DATE, TimeUnit.MILLISECONDS);

+ 3 - 3
specs/017-oauth-login/spec.md

@@ -67,11 +67,11 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 - 第三家以上三方(微信/Facebook)——表结构已可扩展,但本期只接 Apple/Google/LINE。
 - Apple 隐藏邮箱中转、refresh_token 续期等高级场景。
 
-## 2026-09-24 后续增量:登录渠道与第三方名称展示(待实施)
+## 2026-09-24 后续增量:登录渠道与第三方名称展示(后端已于 2026-09-28 实施)
 
 - 用户端分别展示原有 `info_user.nick_name`、本次登录渠道(Apple / LINE)及该渠道的第三方名称。用户修改昵称仍走原有资料修改流程;切换登录渠道不得覆盖 `info_user.nick_name`,也不得把不同 `user_id` 的三方身份视为同一账号。
 - `info_user_oauth.user_id` 只表示账号绑定关系,不表示“最近一次登录”。当前 `provider` 记录在本次登录的 JWT 会话中,`info_user` 不保存最近登录渠道;本增量展示的是本次会话的登录方式,不是跨设备共享的历史“最后一次登录方式”。
-- 第三方名称按绑定身份独立保存于 `info_user_oauth.provider_display_name`(新增可空字段);本次登录为 LINE 时显示 LINE 名称,为 Apple 时显示 Apple 名称,不使用其他渠道的名称。当前表尚无此字段,待实施后才可用。
+- 第三方名称按绑定身份独立保存于 `info_user_oauth.provider_display_name`(新增可空字段);本次登录为 LINE 时显示 LINE 名称,为 Apple 时显示 Apple 名称,不使用其他渠道的名称。字段已于 2026-09-28 增加(SQL 待手动执行,历史行保持空值)。
 - LINE:每次成功登录且取得带 `profile` 权限的资料时,读取 `displayName` 并刷新对应绑定记录;获取失败时保留已保存的名称,不影响登录。
 - Apple:首次授权取得用户共享的 `fullName` 时保存到对应绑定记录;后续登录不要求再次取得名称。历史账号未保存过 Apple 名称或用户未共享名称时,该名称允许为空,用户端仅展示“Apple 登录”及原有昵称,不伪造 Apple 名称。
-- 本增量仅记录需求,尚未修改 App、后端、数据库或现有账号数据;实施时须同步更新接口契约、数据模型、SQL 和验证用例。
+- 2026-09-28 后端实施完成(实施记录见 tasks.md):App 端展示联调与真机验证待 App 源码接入,数据库 SQL 待手动执行。

+ 7 - 5
specs/017-oauth-login/tasks.md

@@ -69,8 +69,10 @@
 
 ## 2026-09-24 后续增量待办:登录渠道与第三方名称展示
 
-- [ ] T29 为 `info_user_oauth` 增加可空字段 `provider_display_name`,并将迁移 SQL 记录到 `updatesql/sql.md`(不直接执行)。
-- [ ] T30 LINE 登录每次成功读取并刷新本渠道 `displayName`;读取失败保留旧值、不阻断登录。
-- [ ] T31 App 首次 Apple 授权取得 `fullName` 时传给后端;后端验证身份后保存到对应 Apple 绑定,历史无名称允许为空。
-- [ ] T32 用户资料接口与 App 分别展示原有昵称、本次登录渠道及对应第三方名称;昵称修改流程不变,空名称只显示渠道。
-- [ ] T33 编写并运行渠道切换、跨账号隔离、LINE 名称更新、Apple 首次/历史空值及 App 展示的定向验证。
+- [x] T29 为 `info_user_oauth` 增加可空字段 `provider_display_name`,并将迁移 SQL 记录到 `updatesql/sql.md`(不直接执行)。
+- [x] T30 LINE 登录每次成功读取并刷新本渠道 `displayName`;读取失败保留旧值、不阻断登录。
+- [x] T31 App 首次 Apple 授权取得 `fullName` 时传给后端;后端验证身份后保存到对应 Apple 绑定,历史无名称允许为空。
+- [x] T32 用户资料接口与 App 分别展示原有昵称、本次登录渠道及对应第三方名称;昵称修改流程不变,空名称只显示渠道。
+- [x] T33 编写并运行渠道切换、跨账号隔离、LINE 名称更新、Apple 首次/历史空值及 App 展示的定向验证。
+
+> 实施记录(2026-09-28,test-202609v2):后端全部完成——`OAuthIdentity` 随凭证校验带回 displayName,`OAuthLoginDto.fullName` 承载 Apple 首次授权姓名,tempKey 缓存值三段式 `provider@providerUid@displayName`(空名称保持两段式,兼容旧值);JWT 新增 `providerUid` claim(gettoken 续签保真);getuserinfo 按会话渠道 + userId+providerUid 精确绑定回填 `oauthProvider`/`providerDisplayName`,旧会话无 providerUid 只展示渠道不任意挑绑定;LINE 直连与回调两条入口均在成功登录时刷新 displayName(空/未变保留旧值,失败不阻断)。定向测试 100 通过 + 全模块回归 759 tests 0 failures/0 errors。SQL 未执行(updatesql/sql.md 待手动执行);App 端展示联调待 App 源码接入(与 T28 同为外部待办)。

+ 6 - 0
updatesql/sql.md

@@ -1778,3 +1778,9 @@ UPDATE push_message SET is_read = 1;
 ALTER TABLE push_message ADD COLUMN dd_id VARCHAR(32) DEFAULT NULL COMMENT '关联订单号(外卖ddId/闪送orderNo)';
 ALTER TABLE push_message ADD COLUMN push_type TINYINT DEFAULT NULL COMMENT '推送业务类型 1外卖 2充值 3闪送';
 ```
+
+```sql
+-- 2026-09-28 三方登录第三方名称展示(017 2026-09-24 增量)
+-- LINE 登录刷新 displayName、Apple 首次授权保存 App 提交的 fullName;仅新登录写入,历史行保持空值(App 名称为空只展示渠道)
+ALTER TABLE info_user_oauth ADD COLUMN provider_display_name VARCHAR(128) DEFAULT NULL COMMENT '第三方显示名称(LINE displayName/Apple fullName,可空)';
+```