Explorar o código

管理员后端登录管理调整

liuchengsen hai 1 mes
pai
achega
1a9a8d6592

+ 359 - 0
docs/第三方登录接口文档.md

@@ -0,0 +1,359 @@
+# 第三方登录接口文档
+
+> 版本:V1.1  
+> 更新日期:2026-07-07  
+> 变更说明:扩展请求参数,支持传入用户名、药店名称、地址、联系人等用户信息
+
+---
+
+## 1. 概述
+
+本接口用于第三方系统通过用户手机号换取登录 Token,实现免密登录。系统会自动查找或注册用户,返回访问令牌和刷新令牌。同时支持第三方系统传入用户详细信息(用户名、药店名称、地址、联系人等),由我方负责存储,第三方无需我方再次维护用户信息。
+
+**核心特性:**
+- 按手机号自动查找或注册用户
+- 支持第三方传入用户名、药店信息等,自动保存到用户表
+- 不递增 `tokenVersion`,不踢出 PC 端已有会话
+- 新用户自动初始化 PLUS 会员
+
+---
+
+## 2. 接口信息
+
+| 项目 | 说明 |
+|------|------|
+| **接口名称** | 外部系统换取小程序 Token |
+| **请求方式** | `POST` |
+| **接口路径** | `/api/auth/external/miniapp-token` |
+| **Content-Type** | `application/json` |
+| **字符编码** | `UTF-8` |
+
+---
+
+## 3. 鉴权方式
+
+使用 **API Key** 鉴权,在请求头中传入:
+
+```
+X-Api-Key: {我方提供的API Key}
+```
+
+> **API Key 格式建议**:32 位随机十六进制字符串(大小写字母 + 数字)。  
+> 参考示例:`a3f8c21e67b94d0e5f1a6c8d3b9e2071`
+>
+> ⚠️ 请妥善保管 API Key,不要泄露到前端代码或客户端。请在后端服务中调用此接口。
+
+---
+
+## 4. 请求参数
+
+### 4.1 请求头(Headers)
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `X-Api-Key` | String | 是 | 接口鉴权密钥 |
+| `Content-Type` | String | 是 | 固定值 `application/json` |
+
+### 4.2 请求体(Body)
+
+#### 基础字段
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `phone` | String | 是 | 用户手机号 |
+| `inviteCode` | String | 否 | 邀请码(仅新用户注册时生效) |
+
+#### 用户信息字段(第三方传入,自动保存)
+
+| 参数名 | 类型 | 必填 | 说明 | 对应我方字段 |
+|--------|------|------|------|--------------|
+| `username` | String | 否 | 用户名(保存到 nickname 字段,优先级高于 nickname) | `nickname` |
+| `nickname` | String | 否 | 昵称(不传则使用 username,再不传则按手机号生成) | `nickname` |
+| `pharmacyName` | String | 否 | 药店名称 | `pharmacy_name` |
+| `province` | String | 否 | 省 | `province` |
+| `city` | String | 否 | 市 | `city` |
+| `district` | String | 否 | 区 | `district` |
+| `pharmacyAddress` | String | 否 | 药店详细地址 | `pharmacy_address` |
+| `contactPerson` | String | 否 | 联系人 | `contact_person` |
+| `contactPhone` | String | 否 | 联系电话 | `contact_phone` |
+| `businessLicenseNo` | String | 否 | 营业执照号 | `business_license_no` |
+
+> 💡 **字段更新规则**:
+> - 新用户会写入所有传入字段
+> - 已存在用户仅更新非空字段(不会清空已有数据)
+>
+> 💡 **nickname 优先级**:`username` > `nickname` > 手机号生成(如 `用户8000`)
+>
+> 💡 **建议**:第三方每次调用时传入最新的用户信息,我方会自动同步更新,无需额外维护。
+
+### 4.3 请求示例
+
+#### 最简请求(仅必填字段)
+
+```json
+{
+  "phone": "13800138000"
+}
+```
+
+#### 完整请求(含用户信息)
+
+```json
+{
+  "phone": "13800138000",
+  "inviteCode": "ABC123",
+  "username": "yaohuigou001",
+  "pharmacyName": "北京同仁堂大药房",
+  "province": "北京市",
+  "city": "北京市",
+  "district": "朝阳区",
+  "pharmacyAddress": "建国路88号",
+  "contactPerson": "张三",
+  "contactPhone": "13800138000",
+  "businessLicenseNo": "91110105MA12345678"
+}
+```
+
+---
+
+## 5. 响应参数
+
+### 5.1 通用响应结构
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": { }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `code` | int | 状态码,`200` 表示成功 |
+| `message` | String | 提示信息 |
+| `data` | Object | 登录响应数据 |
+
+### 5.2 data 字段
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| `userId` | String | 用户ID(字符串,避免JS大数精度丢失) |
+| `accessToken` | String | 访问令牌 |
+| `refreshToken` | String | 刷新令牌 |
+| `expiresIn` | Long | accessToken有效期(秒),默认`7200` |
+| `newUser` | Boolean | 是否为新注册用户 |
+| `needBindPhone` | Boolean | 始终返回`false` |
+| `role` | String | 固定值`"USER"` |
+| `userInfo` | Object | 用户基本信息 |
+
+### 5.3 userInfo 字段说明
+
+| 参数名 | 类型 | 说明 |
+|--------|------|------|
+| `id` | String | 用户ID |
+| `phone` | String | 手机号 |
+| `nickname` | String | 昵称(即第三方传入的 username 或 nickname) |
+| `avatar` | String | 头像 URL |
+| `wechatBound` | Boolean | 是否已绑定微信 |
+| `loginSource` | String | 登录来源,`"MINIAPP"` 表示小程序 |
+| `membershipLevel` | String | 会员等级代码 |
+| `membershipLevelName` | String | 会员等级名称 |
+| `createTime` | String | 注册时间 |
+
+### 5.4 响应示例
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "userId": "1234567890123456789",
+    "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
+    "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
+    "expiresIn": 7200,
+    "newUser": false,
+    "needBindPhone": false,
+    "role": "USER",
+    "userInfo": {
+      "id": "1234567890123456789",
+      "phone": "138****8000",
+      "nickname": "yaohuigou001",
+      "avatar": null,
+      "wechatBound": false,
+      "loginSource": "MINIAPP",
+      "membershipLevel": "PLUS",
+      "membershipLevelName": "PLUS会员",
+      "createTime": "2025-01-01T12:00:00"
+    }
+  }
+}
+```
+
+---
+
+## 6. 错误码
+
+| 错误码 | 说明 | 处理建议 |
+|--------|------|----------|
+| `200` | 成功 | — |
+| `401` | API Key 无效或缺失 | 检查`X-Api-Key`请求头 |
+| `400` | 参数校验失败 | 检查`phone`是否为空 |
+| `500` | 服务器内部错误 | 重试,持续失败联系我方 |
+
+---
+
+## 7. Token 使用说明
+
+### 7.1 携带 Token
+
+后续所有业务接口请求需在请求头中携带 accessToken:
+
+```
+Authorization: Bearer {accessToken}
+```
+
+### 7.2 Token 刷新
+
+accessToken 过期后,使用 refreshToken 换取新 Token:
+
+```
+POST /api/auth/refresh
+Content-Type: application/json
+
+{ "refreshToken": "{refreshToken}" }
+```
+
+> 刷新后旧 refreshToken 立即失效(Token 旋转机制)。
+
+### 7.3 心跳保活
+
+建议客户端定时调用心跳接口,保持登录状态:
+
+```
+POST /api/auth/heartbeat
+Authorization: Bearer {currentAccessToken}
+```
+
+> 建议间隔 = accessToken有效期 × 80%,约5760秒。
+
+---
+
+## 8. 注意事项
+
+| # | 说明 |
+|---|------|
+| 1 | PC端严格单设备,小程序与PC互不干扰,同一账号可同时登录 |
+| 2 | 手机号未注册时自动创建账号并初始化PLUS会员 |
+| 3 | `inviteCode`仅新用户注册时生效 |
+| 4 | API Key请在后端调用,勿暴露到客户端 |
+| 5 | accessToken默认2小时,refreshToken默认7天 |
+| 6 | 每次刷新后旧refreshToken立即作废,请保存最新的 |
+| 7 | 用户信息字段(username、药店信息等)每次调用都会同步更新,建议传入最新数据 |
+| 8 | `username` 会保存到我方的 `nickname` 字段,不需要额外维护用户名表 |
+
+---
+
+## 9. 调用示例
+
+### cURL
+
+```bash
+curl -X POST "https://your-domain.com/api/auth/external/miniapp-token" \
+  -H "Content-Type: application/json" \
+  -H "X-Api-Key: your-api-key-here" \
+  -d '{
+    "phone": "13800138000",
+    "username": "yaohuigou001",
+    "pharmacyName": "北京同仁堂大药房",
+    "province": "北京市",
+    "city": "北京市",
+    "district": "朝阳区",
+    "pharmacyAddress": "建国路88号",
+    "contactPerson": "张三",
+    "contactPhone": "13800138000",
+    "businessLicenseNo": "91110105MA12345678"
+  }'
+```
+
+### Java (OkHttp)
+
+```java
+OkHttpClient client = new OkHttpClient();
+
+String json = "{"
+    + "\"phone\": \"13800138000\","
+    + "\"username\": \"yaohuigou001\","
+    + "\"pharmacyName\": \"北京同仁堂大药房\""
+    + "}";
+
+RequestBody body = RequestBody.create(json, MediaType.parse("application/json"));
+Request request = new Request.Builder()
+    .url("https://your-domain.com/api/auth/external/miniapp-token")
+    .post(body)
+    .addHeader("X-Api-Key", "your-api-key-here")
+    .addHeader("Content-Type", "application/json")
+    .build();
+
+try (Response response = client.newCall(request).execute()) {
+    System.out.println(response.body().string());
+}
+```
+
+### Python (requests)
+
+```python
+import requests
+
+url = "https://your-domain.com/api/auth/external/miniapp-token"
+headers = {
+    "X-Api-Key": "your-api-key-here",
+    "Content-Type": "application/json"
+}
+data = {
+    "phone": "13800138000",
+    "username": "yaohuigou001",
+    "pharmacyName": "北京同仁堂大药房",
+    "province": "北京市",
+    "city": "北京市",
+    "district": "朝阳区"
+}
+
+response = requests.post(url, json=data, headers=headers)
+print(response.json())
+```
+
+---
+
+## 10. 字段映射表
+
+第三方传入字段与我方数据库字段的对应关系:
+
+| 第三方传入字段 | 我方数据库字段 | 我方实体字段 | 说明 |
+|----------------|----------------|--------------|------|
+| `phone` | `phone` | `phone` | 手机号(必填) |
+| `username` | `nickname` | `nickname` | 用户名(保存到 nickname) |
+| `nickname` | `nickname` | `nickname` | 昵称(优先级低于 username) |
+| `pharmacyName` | `pharmacy_name` | `pharmacyName` | 药店名称 |
+| `province` | `province` | `province` | 省 |
+| `city` | `city` | `city` | 市 |
+| `district` | `district` | `district` | 区 |
+| `pharmacyAddress` | `pharmacy_address` | `pharmacyAddress` | 药店详细地址 |
+| `contactPerson` | `contact_person` | `contactPerson` | 联系人 |
+| `contactPhone` | `contact_phone` | `contactPhone` | 联系电话 |
+| `businessLicenseNo` | `business_license_no` | `businessLicenseNo` | 营业执照号 |
+
+---
+
+## 11. 联系方式
+
+如有问题,请联系我方技术支持。
+
+---
+
+## 12. 变更记录
+
+| 版本 | 日期 | 变更内容 |
+|------|------|----------|
+| V1.0 | 2026-07-07 | 初始版本:基础登录接口 |
+| V1.1 | 2026-07-07 | 扩展请求参数:支持传入用户名、药店信息等,自动保存到用户表 |

+ 10 - 0
zhijiayun-gateway/src/main/resources/db/migration-v9.sql

@@ -0,0 +1,10 @@
+-- ============================================
+-- 迁移 v9:第三方登录扩展字段(药店详细信息)
+-- ============================================
+
+-- 1. t_user 扩展字段(第三方系统传入的药店信息)
+ALTER TABLE `t_user`
+    ADD COLUMN `pharmacy_address` VARCHAR(255) DEFAULT NULL COMMENT '药店详细地址' AFTER `district`,
+    ADD COLUMN `contact_person` VARCHAR(64) DEFAULT NULL COMMENT '联系人' AFTER `pharmacy_address`,
+    ADD COLUMN `contact_phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话' AFTER `contact_person`,
+    ADD COLUMN `business_license_no` VARCHAR(64) DEFAULT NULL COMMENT '营业执照号' AFTER `contact_phone`;

+ 31 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/ExternalTokenRequest.java

@@ -15,4 +15,34 @@ public class ExternalTokenRequest {
 
     /** 可选:邀请码 */
     private String inviteCode;
-}
+
+    /** 可选:用户名(第三方系统传入,保存到 nickname 字段) */
+    private String username;
+
+    /** 可选:昵称(第三方系统传入,若不传则按手机号生成) */
+    private String nickname;
+
+    /** 可选:药店名称 */
+    private String pharmacyName;
+
+    /** 可选:省 */
+    private String province;
+
+    /** 可选:市 */
+    private String city;
+
+    /** 可选:区 */
+    private String district;
+
+    /** 可选:药店详细地址 */
+    private String pharmacyAddress;
+
+    /** 可选:联系人 */
+    private String contactPerson;
+
+    /** 可选:联系电话 */
+    private String contactPhone;
+
+    /** 可选:营业执照号 */
+    private String businessLicenseNo;
+}

+ 13 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/entity/User.java

@@ -65,6 +65,18 @@ public class User {
     /** 区 */
     private String district;
 
+    /** 药店详细地址 */
+    private String pharmacyAddress;
+
+    /** 联系人 */
+    private String contactPerson;
+
+    /** 联系电话 */
+    private String contactPhone;
+
+    /** 营业执照号 */
+    private String businessLicenseNo;
+
     /** 最后登录设备信息 */
     private String lastLoginDevice;
 
@@ -107,4 +119,4 @@ public class User {
     /** 更新时间 */
     @TableField(fill = FieldFill.INSERT_UPDATE)
     private LocalDateTime updateTime;
-}
+}

+ 11 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -150,8 +150,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
      * <p>
      * 普通用户:
      * <ul>
-     *   <li>PC 端 / 旧 token(loginSource 为空或非 MINIAPP):精确匹配,严格单设备</li>
      *   <li>小程序端(loginSource=MINIAPP):范围匹配 [current-1, current],与 PC 共存</li>
+     *   <li>旧 token(loginSource 为空或 UNKNOWN,修复前签发):范围匹配,向前兼容,给老用户宽限期</li>
+     *   <li>PC 端(loginSource=PC):精确匹配,严格单设备</li>
      * </ul>
      */
     private boolean isTokenVersionValid(Long id, String loginSource,
@@ -189,7 +190,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
             return tokenVer <= currentVersion && (currentVersion - tokenVer) < 2;
         }
 
-        // PC 端 / 旧 token(loginSource 为空):精确匹配,严格单设备(向前兼容)
+        // 旧 token(loginSource 为空或 UNKNOWN,即修复前签发的 token):
+        // 走范围匹配,给老用户一个宽限期,避免 tokenVersion 变化后被立即踢出。
+        // 真正的向前兼容:旧 token 容忍 2 个版本差异,平滑过渡到新 token。
+        if (loginSource == null || "UNKNOWN".equals(loginSource)) {
+            return tokenVer <= currentVersion && (currentVersion - tokenVer) < 2;
+        }
+
+        // PC 端(明确 loginSource=PC):精确匹配,严格单设备
         return currentVersion.equals(tokenVer);
     }
 
@@ -259,4 +267,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
         }
         return claims;
     }
-}
+}

+ 2 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminAuthService.java

@@ -67,8 +67,8 @@ public class AdminAuthService {
         long newVersion = oldVersion + 1;
         admin.setTokenVersion(newVersion);
 
-        String accessToken = jwtUtil.generateAccessToken(admin.getId(), roleCode, newVersion);
-        String refreshToken = jwtUtil.generateRefreshToken(admin.getId());
+        String accessToken = jwtUtil.generateAccessToken(admin.getId(), roleCode, "PC", newVersion);
+        String refreshToken = jwtUtil.generateRefreshToken(admin.getId(), "PC");
 
         admin.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
         admin.setRefreshTokenStatus("ACTIVE");

+ 67 - 22
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -94,6 +94,7 @@ public class AuthService {
         // 3. 更新登录信息
         user.setLastLoginTime(LocalDateTime.now());
         user.setLastLoginIp(ip);
+        user.setLoginSource("PC");
         userMapper.updateById(user);
 
         // 4. 生成Token
@@ -130,6 +131,7 @@ public class AuthService {
         if (user != null && user.getPhone() != null) {
             user.setLastLoginTime(LocalDateTime.now());
             user.setLastLoginIp(ip);
+            user.setLoginSource("PC");
             userMapper.updateById(user);
             return buildLoginResponse(user);
         }
@@ -211,6 +213,7 @@ public class AuthService {
             existUser.setWechatUnionId(unionId);
             existUser.setLastLoginTime(LocalDateTime.now());
             existUser.setLastLoginIp(ip);
+            existUser.setLoginSource("PC");
             userMapper.updateById(existUser);
 
             // 删除旧的微信用户
@@ -219,7 +222,7 @@ public class AuthService {
         } else if (existUser == null) {
             // 绑定手机号到微信用户(首次完成注册)
             wechatUser.setPhone(request.getPhone());
-            wechatUser.setLoginSource("WECHAT");
+            wechatUser.setLoginSource("PC");
             wechatUser.setLastLoginTime(LocalDateTime.now());
             wechatUser.setLastLoginIp(ip);
             userMapper.updateById(wechatUser);
@@ -234,12 +237,7 @@ public class AuthService {
         return buildLoginResponse(wechatUser);
     }
 
-    /**
-     * 心跳保活(零DB查询,仅一次blind UPDATE,高性能)
-     * <p>
-     * 不递增 tokenVersion(同一设备续期),使用 JWT 中已有信息签发新 Token。
-     * 过滤层已校验 tokenVersion 和用户存在性,这里直接签发。
-     */
+
     /**
      * 外部系统换取小程序 Token(供小程序后台按手机号签发)
      * <p>查找或自动注册用户,不递增 tokenVersion,不踢 PC。
@@ -252,9 +250,23 @@ public class AuthService {
         boolean isNewUser = false;
         if (user == null) {
             isNewUser = true;
+            // 优先级:nickname > username > 手机号生成
+            String nickname = (request.getNickname() != null && !request.getNickname().isBlank())
+                    ? request.getNickname()
+                    : (request.getUsername() != null && !request.getUsername().isBlank())
+                        ? request.getUsername()
+                        : "用户" + request.getPhone().substring(request.getPhone().length() - 4);
             user = User.builder()
                     .phone(request.getPhone())
-                    .nickname("用户" + request.getPhone().substring(request.getPhone().length() - 4))
+                    .nickname(nickname)
+                    .pharmacyName(request.getPharmacyName())
+                    .province(request.getProvince())
+                    .city(request.getCity())
+                    .district(request.getDistrict())
+                    .pharmacyAddress(request.getPharmacyAddress())
+                    .contactPerson(request.getContactPerson())
+                    .contactPhone(request.getContactPhone())
+                    .businessLicenseNo(request.getBusinessLicenseNo())
                     .membershipLevel(MembershipLevel.PLUS)
                     .loginSource("MINIAPP")
                     .status(1)
@@ -265,6 +277,38 @@ public class AuthService {
                 eventPublisher.publishEvent(
                         new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
             }
+        } else {
+            // 已存在用户:更新第三方传入的信息(非空字段才覆盖,避免清空已有数据)
+            // username 优先级高于 nickname,都保存到 nickname 字段
+            if (request.getUsername() != null && !request.getUsername().isBlank()) {
+                user.setNickname(request.getUsername());
+            } else if (request.getNickname() != null && !request.getNickname().isBlank()) {
+                user.setNickname(request.getNickname());
+            }
+            if (request.getPharmacyName() != null && !request.getPharmacyName().isBlank()) {
+                user.setPharmacyName(request.getPharmacyName());
+            }
+            if (request.getProvince() != null && !request.getProvince().isBlank()) {
+                user.setProvince(request.getProvince());
+            }
+            if (request.getCity() != null && !request.getCity().isBlank()) {
+                user.setCity(request.getCity());
+            }
+            if (request.getDistrict() != null && !request.getDistrict().isBlank()) {
+                user.setDistrict(request.getDistrict());
+            }
+            if (request.getPharmacyAddress() != null && !request.getPharmacyAddress().isBlank()) {
+                user.setPharmacyAddress(request.getPharmacyAddress());
+            }
+            if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
+                user.setContactPerson(request.getContactPerson());
+            }
+            if (request.getContactPhone() != null && !request.getContactPhone().isBlank()) {
+                user.setContactPhone(request.getContactPhone());
+            }
+            if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) {
+                user.setBusinessLicenseNo(request.getBusinessLicenseNo());
+            }
         }
 
         user.setLoginSource("MINIAPP");
@@ -288,12 +332,14 @@ public class AuthService {
             User user = userMapper.selectById(userId);
             currentVersion = user != null && user.getTokenVersion() != null
                     ? user.getTokenVersion() : 0L;
-            if (user != null) tokenVersionCache.putVersion(userId, false, currentVersion);
+            if (user != null) {
+                tokenVersionCache.putVersion(userId, false, currentVersion);
+            }
         }
 
         String newAccessToken = jwtUtil.generateAccessToken(
                 userId, roleCode, loginSource, currentVersion);
-        String newRefreshToken = jwtUtil.generateRefreshToken(userId);
+        String newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource);
 
         User updateUser = new User();
         updateUser.setId(userId);
@@ -332,8 +378,7 @@ public class AuthService {
         java.util.Map<String, Object> quota = buildQuotaSummary(userId);
         java.util.List<?> platforms = getPlatformBindings(userId);
 
-        java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
-        result.putAll(toMap(userInfo));
+        java.util.Map<String, Object> result = new java.util.LinkedHashMap<>(toMap(userInfo));
         result.put("gate", gate);
         result.put("quota", quota);
         result.put("platformBindings", platforms);
@@ -426,7 +471,7 @@ public class AuthService {
         }
 
         // 5. 签发正式 Token
-        LoginResponse response = buildLoginResponse(user);
+        LoginResponse response = buildMiniAppLoginResponse(user);
         auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
                 OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
         return response;
@@ -491,7 +536,6 @@ public class AuthService {
         }).toList();
     }
 
-    @SuppressWarnings("unchecked")
     private java.util.Map<String, Object> toMap(UserInfoResponse info) {
         // 简单反射转 Map,避免引入额外依赖
         java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
@@ -570,10 +614,11 @@ public class AuthService {
         user.setRefreshTokenStatus("USED");
         String roleCode = "USER";
         long currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
+        String loginSource = jwtUtil.getLoginSource(refreshToken);
 
         String newAccessToken = jwtUtil.generateAccessToken(
-                user.getId(), roleCode, currentVersion);
-        String newRefreshToken = jwtUtil.generateRefreshToken(user.getId());
+                user.getId(), roleCode, loginSource, currentVersion);
+        String newRefreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
 
         // 存储新 Refresh Token 哈希
         user.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
@@ -596,18 +641,17 @@ public class AuthService {
     /** PC 端构建登录响应:
      * 递增 tokenVersion,踢掉旧 PC */
     private LoginResponse buildLoginResponse(User user) {
-        return buildLoginResponseInternal(user, true);
+        return buildLoginResponseInternal(user, true, "PC");
     }
 
     /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC */
     private LoginResponse buildMiniAppLoginResponse(User user) {
-        return buildLoginResponseInternal(user, false);
+        return buildLoginResponseInternal(user, false, "MINIAPP");
     }
 
-    private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion) {
+    private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion, String loginSource) {
         MDC.put("uid", String.valueOf(user.getId()));
         String roleCode = "USER";
-        String loginSource = user.getLoginSource() != null ? user.getLoginSource() : "UNKNOWN";
 
         long version;
         if (incrementVersion) {
@@ -621,7 +665,7 @@ public class AuthService {
 
         String accessToken = jwtUtil.generateAccessToken(
                 user.getId(), "USER", loginSource, version);
-        String refreshToken = jwtUtil.generateRefreshToken(user.getId());
+        String refreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
 
         user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
         user.setRefreshTokenStatus("ACTIVE");
@@ -732,6 +776,7 @@ public class AuthService {
         // 5. 更新登录信息
         user.setLastLoginTime(LocalDateTime.now());
         user.setLastLoginIp(ip);
+        user.setLoginSource("PC");
         userMapper.updateById(user);
 
         // 6. 生成Token
@@ -1038,4 +1083,4 @@ public class AuthService {
             log.warn("外部系统注册失败(不阻断主流程): userId={}", user.getId(), e);
         }
     }
-}
+}

+ 13 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/util/JwtUtil.java

@@ -75,12 +75,13 @@ public class JwtUtil {
     }
 
     /**
-     * 生成刷新令牌
+     * 生成刷新令牌(含 loginSource,用于刷新时保留登录来源)
      */
-    public String generateRefreshToken(Long userId) {
+    public String generateRefreshToken(Long userId, String loginSource) {
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
         claims.put("type", "refresh");
+        claims.put("loginSource", loginSource != null ? loginSource : "UNKNOWN");
 
         return Jwts.builder()
                 .claims(claims)
@@ -164,6 +165,15 @@ public class JwtUtil {
         return claims.get("type", String.class);
     }
 
+    /**
+     * 获取登录来源(用于刷新 Token 时保留原始登录来源)
+     */
+    public String getLoginSource(String token) {
+        Claims claims = parseToken(token);
+        String loginSource = claims.get("loginSource", String.class);
+        return loginSource != null ? loginSource : "UNKNOWN";
+    }
+
     /**
      * 对 Refresh Token 取 SHA-256 哈希(存储到DB用于旋转+重用检测)
      */
@@ -188,4 +198,4 @@ public class JwtUtil {
         long expirationMs = isAdminRole(role) ? adminExpiration : userExpiration;
         return expirationMs / 1000;
     }
-}
+}