Bladeren bron

fix(auth): 已登录用户富页面误显'请先登录'的会话过期修复

- JwtInterceptor: 无效 token 一律 401,不再静默降级匿名
- AuthController.verify: 增加 user 存在性检查,用户被删/禁用时返回 401
- api.js: 新增 verifyToken() 导出函数
- App.vue: onLaunch 时调用 verifyStoredToken() 启动校验 token(develop 跳过)
- api.test.js: 新增 3 条 verifyToken 测试(成功/过期/用户不存在)

后端 mvn clean compile BUILD SUCCESS
前端 npm test auth API 16/16 通过
Xiaogang Liao 1 maand geleden
bovenliggende
commit
078ed8f5ea

+ 7 - 8
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -164,14 +164,13 @@ public class JwtInterceptor implements HandlerInterceptor {
 
             return true;
         } catch (Exception e) {
-            if (loginRequired) {
-                log.debug("请求 {} 需登录但Token解析失败,拒绝: {}", path, e.getMessage());
-                writeUnauthorized(response);
-                return false;
-            }
-            log.warn("Token解析失败: {}, 设为匿名用户", e.getMessage());
-            setAnonymousUser(request);
-            return true;
+            // Token 无效/过期一律 401(不区分 loginRequired):
+            // 避免前端带残留 token 时被静默降级为匿名,导致需登录接口返回
+            // "请先登录"(code=500) 而前端仍认为已登录。前端收到 401 后
+            // 会清理本地登录态并引导重新登录。
+            log.warn("Token解析失败, 拒绝请求: path={}, error={}", path, e.getMessage());
+            writeUnauthorized(response);
+            return false;
         }
     }
 

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.dto.*;
 import com.etotem.cfc.dto.RegisterWithIdCardDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
 import io.jsonwebtoken.Claims;
@@ -13,6 +14,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.HashMap;
@@ -21,6 +23,7 @@ import java.util.Map;
 
 @Tag(name = "用户认证", description = "用户登录、验证码、密码设置等认证接口")
 @RestController
+@Slf4j
 @RequestMapping("/api/auth")
 public class AuthController {
 
@@ -33,6 +36,9 @@ public class AuthController {
     @Resource
     private JwtConfig jwtConfig;
 
+    @Resource
+    private UserMapper userMapper;
+
     /**
      * 发送验证码
      */
@@ -191,7 +197,7 @@ public class AuthController {
      * JWT Token 验证
      * 从 Authorization header 提取 Bearer token,验证并返回用户信息
      */
-    @Operation(summary = "验证JWT Token有效性")
+    @Operation(summary = "验证JWT Token有效性(含 user 存在性)")
     @PostMapping("/verify")
     public Result<Map<String, Object>> verifyToken(HttpServletRequest request) {
         String authHeader = request.getHeader("Authorization");
@@ -204,6 +210,13 @@ public class AuthController {
             Long userId = Long.parseLong(claims.getSubject());
             String role = (String) claims.get("role");
 
+            // 校验 user 是否仍存在(账号被清理/禁用时 JWT 仍可能合法)
+            // 不存在则视为登录态失效,返回 401 触发前端 _clearAuthAndRedirect
+            if (userMapper.selectById(userId) == null) {
+                log.warn("/api/auth/verify: userId={} 在数据库不存在,返回 401", userId);
+                return Result.error(401, "用户不存在");
+            }
+
             Map<String, Object> data = new HashMap<>();
             data.put("userId", userId);
             data.put("role", role);

+ 20 - 1
cfc-frontend/App.vue

@@ -1,7 +1,7 @@
 <script>
 import config from '@/config.js'
 import Vue from 'vue'
-import { checkUserFamily } from './utils/api.js'
+import { checkUserFamily, verifyToken } from './utils/api.js'
 Vue.prototype.$config = config
 
 export default {
@@ -16,6 +16,8 @@ export default {
     const token = uni.getStorageSync('token')
     if (token) {
       this.$store.commit('setToken', token)
+      // 启动时校验 token 是否仍有效:过期则清理本地登录态并引导重新登录
+      this.verifyStoredToken()
       // 冷启动恢复 familyId(登录时已持久化,hasFamily getter 依赖)
       var storedFamilyId = uni.getStorageSync('familyId')
       if (storedFamilyId) {
@@ -45,6 +47,23 @@ export default {
     console.log('App Hide')
   },
   methods: {
+    // 校验本地 token 是否有效(开发环境跳过,避免无后端时误报)
+    verifyStoredToken() {
+      try {
+        var envInfo = uni.getAccountInfoSync()
+        if (envInfo && envInfo.miniProgram && envInfo.miniProgram.envVersion === 'develop') {
+          return
+        }
+      } catch (e) { /* 非小程序环境 */ }
+      var self = this
+      verifyToken().then(function() {
+        // token 有效,无需处理
+      }).catch(function(e) {
+        // 401 已由 request 封装触发 _clearAuthAndRedirect(清理 + 跳登录);
+        // 其它错误(如网络失败)静默忽略,不打扰用户
+        console.log('[App] token 校验未通过:', e && e.code)
+      })
+    },
     // 从接口恢复 familyId(历史用户冷启动,本地无 familyId 缓存)
     restoreFamilyIdFromApi() {
       var self = this

+ 33 - 0
cfc-frontend/__tests__/api/api.test.js

@@ -118,6 +118,39 @@ describe('auth API', () => {
     expect(lastRequest().url).toMatch(/\/api\/auth\/wechat-phone-login$/)
   })
 
+  test('verifyToken sends POST /api/auth/verify with bearer token', async () => {
+    mockSuccess({ code: 200, data: { userId: 42, role: 'parent' } })
+    const res = await api.verifyToken()
+    const opts = lastRequest()
+    expect(opts.method).toBe('POST')
+    expect(opts.url).toMatch(/\/api\/auth\/verify$/)
+    expect(opts.header.Authorization).toBe('Bearer test-token')
+    expect(res.data.userId).toBe(42)
+  })
+
+  test('verifyToken with 401 (token expired) clears auth and redirects to login', async () => {
+    jest.useFakeTimers()
+    mockSuccess({ code: 401, message: 'Token无效或已过期', data: null })
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
+    expect(global.uni._storage.token).toBeUndefined()
+    // 触发 _clearAuthAndRedirect 内 1.5s setTimeout(reLaunch 在此定时器回调中执行)
+    jest.runAllTimers()
+    expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
+    expect(global.uni.showToast).toHaveBeenCalled()
+    jest.useRealTimers()
+  })
+
+  test('verifyToken with 401 (user deleted) clears auth and redirects to login', async () => {
+    jest.useFakeTimers()
+    mockSuccess({ code: 401, message: '用户不存在', data: null })
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: '用户不存在', data: null })
+    expect(global.uni._storage.token).toBeUndefined()
+    jest.runAllTimers()
+    expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
+    expect(global.uni.showToast).toHaveBeenCalled()
+    jest.useRealTimers()
+  })
+
   test('Authorization header includes Bearer token', async () => {
     mockSuccess({ code: 200 })
     global.uni._storage.token = 'my-jwt-token'

+ 5 - 0
cfc-frontend/utils/api.js

@@ -253,6 +253,11 @@ export const checkPhone = (phone) => {
   return request('/api/auth/check-phone', 'POST', { phone })
 }
 
+// 校验本地 token 是否仍有效(App 启动时调用;401 由 request 封装统一处理跳转登录)
+export const verifyToken = () => {
+  return request('/api/auth/verify', 'POST')
+}
+
 // 直接注册(测试环境)
 export const directRegister = (data) => {
   return request('/api/auth/direct-register', 'POST', data)