From 995e0664d51095c9b3ef85d557d6c70e522f2249 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sun, 5 Jul 2026 20:18:35 +0800 Subject: [PATCH] feat(auth): implement user ban detection and optional auth path handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add USER_BANNED (403) handling across auth flow: - Extend FetchCurrentUserResult and RefreshResult with 'banned' type - Detect USER_BANNED errorCode in authService and api client - Clear token and show "账号已被封禁,请联系管理员" on ban detection - Throw non-network refresh errors up to caller instead of silent clear Add optional auth path support: - Extract optional auth path prefixes to optionalAuthPaths.ts - Handle 401 on optional auth GET endpoints by clearing expired token and retrying as guest (without triggering navigateToLogin) - Export helpers for existing consumers Handle backend behavior change: - Guest logout now returns 401 instead of 200; treat as silent success Add tests for optional auth path logic. --- src/services/auth/authService.ts | 41 +++- .../core/__tests__/optionalAuthPaths.test.ts | 218 ++++++++++++++++++ src/services/core/api.ts | 74 +++++- src/services/core/errorHandler.ts | 4 + src/services/core/optionalAuthPaths.ts | 44 ++++ src/stores/auth/authStore.ts | 16 +- 6 files changed, 385 insertions(+), 12 deletions(-) create mode 100644 src/services/core/__tests__/optionalAuthPaths.test.ts create mode 100644 src/services/core/optionalAuthPaths.ts diff --git a/src/services/auth/authService.ts b/src/services/auth/authService.ts index 79dd425..d71a7f7 100644 --- a/src/services/auth/authService.ts +++ b/src/services/auth/authService.ts @@ -11,6 +11,7 @@ import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO } * 当前用户获取结果。 * - user:成功拿到用户信息 * - auth_failed:后端明确拒绝(401 / token 失效)→ 应登出 + * - banned:后端返回 403 USER_BANNED(用户被封禁)→ 提示"账号已被封禁" * - network_error:网络故障(未到达后端)→ 不应登出,应保留登录态等待重试 * * 引入该类型是为了修复"断网即被登出"的问题: @@ -20,6 +21,7 @@ import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO } export type FetchCurrentUserResult = | { kind: 'user'; user: User } | { kind: 'auth_failed' } + | { kind: 'banned' } | { kind: 'network_error' }; export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string { @@ -235,11 +237,20 @@ class AuthService { } // 用户登出(只管 API + Token,DB/缓存清理交给 authStore) + // + // 后端行为变化:游客调用 /auth/logout 现在返回 401(之前返回 200)。 + // 客户端策略:401 视为正常路径(用户已处于登出状态),静默吞掉, + // 避免在 finally 清 token 之外产生误导性的错误日志。 async logout(): Promise { try { await api.post('/auth/logout'); - } catch (error) { - console.error('[AuthService] 登出 API 失败:', error); + } catch (error: any) { + const code = error?.code ?? 0; + if (code === 401) { + // 游客调用登出 → 401,属正常情况(用户已是登出状态),静默处理 + } else { + console.error('[AuthService] 登出 API 失败:', error); + } } finally { await api.clearToken(); } @@ -262,6 +273,12 @@ class AuthService { console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error); return { kind: 'network_error' }; } + // 用户被封禁:后端返回 403 + error_code=USER_BANNED。 + // 单独返回 'banned',避免与"token 失效"混淆(提示文案不同)。 + const errorCodeStr = String(error?.errorCode ?? '').toUpperCase(); + if (errorCodeStr === 'USER_BANNED') { + return { kind: 'banned' }; + } // 后端明确拒绝(401 等):认证失败 const code = error?.code ?? 0; const isAuthError = @@ -279,28 +296,35 @@ class AuthService { } // 刷新Token + // + // 注意:后端 rotate 后 refresh_token 是一次性使用的, + // 成功响应中后端必然返回新的 refresh_token,这里会存新的。 + // 网络故障时保留旧 token 不清空,等网络恢复后重试。 + // 非网络错误(如 USER_BANNED、token 失效)向上抛出,由调用方决策。 async refreshToken(): Promise { try { const refreshToken = await api.getRefreshToken(); if (!refreshToken) { return null; } - + const response = await api.post('/auth/refresh', { refresh_token: refreshToken, }); - + if (!response.data) { return null; } if (response.data.token) { await api.setToken(response.data.token); } + // refresh_token rotate:后端返回新的 refresh_token 时存新的; + // 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。 const newRefreshToken = response.data.refresh_token || response.data.refreshToken; if (newRefreshToken) { await api.setRefreshToken(newRefreshToken); } - + return response.data; } catch (error) { // 网络故障:保留 token,等网络恢复后重试,避免误判登出 @@ -308,9 +332,10 @@ class AuthService { console.warn('[AuthService] 刷新Token网络失败(保留登录态):', error); return null; } - console.error('刷新Token失败:', error); - await api.clearToken(); - return null; + // 非网络错误(USER_BANNED、token 失效等):向上抛出, + // 由调用方根据 errorCode 决定提示文案与是否登出。 + console.error('[AuthService] 刷新Token失败:', error); + throw error; } } diff --git a/src/services/core/__tests__/optionalAuthPaths.test.ts b/src/services/core/__tests__/optionalAuthPaths.test.ts new file mode 100644 index 0000000..abc9b6e --- /dev/null +++ b/src/services/core/__tests__/optionalAuthPaths.test.ts @@ -0,0 +1,218 @@ +/** + * OptionalAuth 路径白名单与 401 重试策略单测 + * + * 后端 OptionalAuth 中间件行为变化:携带过期 token 不再静默降级为游客, + * 而是返回 401。前端用此白名单识别这些"对游客开放"的接口, + * 在 401 时清掉本地过期 token 并以游客身份重试一次。 + * + * 策略:仅 GET 请求应用 OptionalAuth 重试(避免误伤 RequireAuth 写接口)。 + */ + +import { + isOptionalAuthPath, + shouldRetryAsGuestOn401, + OPTIONAL_AUTH_PATH_PREFIXES, +} from '../optionalAuthPaths'; + +describe('isOptionalAuthPath', () => { + describe('应识别为 OptionalAuth 路径(返回 true)', () => { + const optionalAuthPaths = [ + // /posts 全系列 + '/posts', + '/posts?page=1', + '/posts/search', + '/posts/suggest', + '/posts/abc123', + '/posts/abc123/view', + '/posts/abc123/share', + '/posts/abc123/related', + '/posts/abc123/refs', + '/posts/abc123/ref-click', + '/posts/abc123/vote', + // /comments + '/comments/post/abc', + '/comments/post/abc/cursor', + '/comments/abc123', + '/comments/abc123/replies', + '/comments/abc123/replies/flat', + // /users/search(必须先于 /users/ 通配命中) + '/users/search', + '/users/search?keyword=foo', + // /users/:id 系列(OptionalAuth) + '/users/abc123', + '/users/abc123/posts', + '/users/abc123/favorites', + // /trade + '/trade', + '/trade?trade_type=sale', + '/trade/abc123', + '/trade/abc123/view', + ]; + + for (const p of optionalAuthPaths) { + it(`返回 true:${p}`, () => { + expect(isOptionalAuthPath(p)).toBe(true); + }); + } + }); + + describe('不应识别为 OptionalAuth 路径(返回 false)', () => { + const nonOptionalPaths = [ + // /users/me* 是 RequireAuth,必须排除 + '/users/me', + '/users/me/', + '/users/me/email/verify', + '/users/me/email/send-code', + '/users/me/privacy', + '/users/me/deactivate', + '/users/me/deletion-status', + // /auth/* 是公开路径(无需 token) + '/auth/login', + '/auth/register', + '/auth/refresh', + '/auth/logout', + '/auth/password/reset', + '/auth/check-username', + '/auth/qrcode', + // /uploads/* 是 RequireAuth + '/uploads/files', + '/uploads/images', + // 其他 RequireAuth 接口 + '/notifications', + '/conversations', + ]; + + for (const p of nonOptionalPaths) { + it(`返回 false:${p}`, () => { + expect(isOptionalAuthPath(p)).toBe(false); + }); + } + }); + + describe('边界情况', () => { + it('空字符串返回 false', () => { + expect(isOptionalAuthPath('')).toBe(false); + }); + + it('仅前缀斜杠返回 false', () => { + expect(isOptionalAuthPath('/')).toBe(false); + }); + + it('/users/me 前缀的精确匹配排除(不误伤 /users/mexico)', () => { + // /users/mexico 不应被 /users/me 排除规则误伤,但也不在白名单 → false + expect(isOptionalAuthPath('/users/mexico')).toBe(false); + }); + + it('/users/search 必须命中(即使 /users/ 也在白名单中)', () => { + expect(isOptionalAuthPath('/users/search')).toBe(true); + }); + }); + + describe('OPTIONAL_AUTH_PATH_PREFIXES 完整性', () => { + it('白名单包含全部 5 个前缀', () => { + expect(OPTIONAL_AUTH_PATH_PREFIXES).toEqual([ + '/posts', + '/comments', + '/users/search', + '/users/', + '/trade', + ]); + }); + + it('/users/search 排在 /users/ 之前(优先级,避免被通配吞掉)', () => { + const searchIdx = OPTIONAL_AUTH_PATH_PREFIXES.indexOf('/users/search'); + const usersIdx = OPTIONAL_AUTH_PATH_PREFIXES.indexOf('/users/'); + expect(searchIdx).toBeGreaterThanOrEqual(0); + expect(usersIdx).toBeGreaterThanOrEqual(0); + expect(searchIdx).toBeLessThan(usersIdx); + }); + }); +}); + +describe('shouldRetryAsGuestOn401', () => { + describe('GET 请求:OptionalAuth 路径应允许游客重试', () => { + const getOptionalPaths = [ + ['GET', '/posts'], + ['GET', '/posts/search'], + ['GET', '/posts/abc123'], + ['GET', '/posts/abc123/related'], + ['GET', '/posts/abc123/vote'], + ['GET', '/comments/post/abc'], + ['GET', '/comments/abc123/replies'], + ['GET', '/users/search'], + ['GET', '/users/abc123'], + ['GET', '/users/abc123/posts'], + ['GET', '/trade'], + ['GET', '/trade/abc123'], + ] as const; + + for (const [method, path] of getOptionalPaths) { + it(`返回 true:${method} ${path}`, () => { + expect(shouldRetryAsGuestOn401(method, path)).toBe(true); + }); + } + }); + + describe('GET 请求:RequireAuth/公开路径不应游客重试', () => { + const getNonOptionalPaths = [ + ['GET', '/users/me'], + ['GET', '/users/me/email/verify'], + ['GET', '/auth/login'], + ['GET', '/notifications'], + ['GET', '/conversations'], + ] as const; + + for (const [method, path] of getNonOptionalPaths) { + it(`返回 false:${method} ${path}`, () => { + expect(shouldRetryAsGuestOn401(method, path)).toBe(false); + }); + } + }); + + describe('写请求(POST/PUT/DELETE):一律不游客重试', () => { + // 即便 path 命中 OptionalAuth 白名单,写接口也走 RequireAuth refresh 流程, + // 避免把"本可 refresh 续期"的用户误判为登出。 + const writeRequests = [ + // OptionalAuth 写接口(合法但策略上仍走 refresh) + ['POST', '/posts/abc123/view'], + ['POST', '/posts/abc123/share'], + ['POST', '/posts/abc123/ref-click'], + ['POST', '/trade/abc123/view'], + // RequireAuth 写接口(必须走 refresh) + ['POST', '/posts'], + ['POST', '/posts/abc123/like'], + ['DELETE', '/posts/abc123/like'], + ['POST', '/posts/abc123/favorite'], + ['DELETE', '/posts/abc123/favorite'], + ['POST', '/posts/vote'], + ['POST', '/posts/abc123/vote'], + ['DELETE', '/posts/abc123/vote'], + ['POST', '/trade'], + ['PUT', '/trade/abc123'], + ['DELETE', '/trade/abc123'], + ['POST', '/trade/abc123/favorite'], + ['DELETE', '/trade/abc123/favorite'], + // 其他 RequireAuth 写接口 + ['POST', '/auth/logout'], + ['POST', '/users/me/deactivate'], + ['PUT', '/users/me/privacy'], + ] as const; + + for (const [method, path] of writeRequests) { + it(`返回 false:${method} ${path}`, () => { + expect(shouldRetryAsGuestOn401(method, path)).toBe(false); + }); + } + }); + + describe('大小写与方法名归一化', () => { + it('小写方法名也能识别', () => { + expect(shouldRetryAsGuestOn401('get', '/posts')).toBe(true); + expect(shouldRetryAsGuestOn401('Get', '/posts')).toBe(true); + }); + + it('空方法返回 false', () => { + expect(shouldRetryAsGuestOn401('', '/posts')).toBe(false); + }); + }); +}); diff --git a/src/services/core/api.ts b/src/services/core/api.ts index 8747c41..6156ce7 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -12,6 +12,14 @@ import { Platform } from 'react-native'; import { eventBus } from '@/core/events/EventBus'; import { showVerificationModal } from './verification'; +import { + OPTIONAL_AUTH_PATH_PREFIXES, + isOptionalAuthPath, + shouldRetryAsGuestOn401, +} from './optionalAuthPaths'; + +// 重新导出,便于现有从 '@/services/core/api' 导入的调用方使用 +export { OPTIONAL_AUTH_PATH_PREFIXES, isOptionalAuthPath, shouldRetryAsGuestOn401 }; // 生产地址 https://withyou.littlelan.cn const getBaseUrl = () => { @@ -76,7 +84,10 @@ interface JwtPayload { // - 'ok' 刷新成功 // - 'auth_failed' 后端明确拒绝(refresh token 失效)→ 可安全登出 // - 'network_error' 网络故障(未收到后端响应)→ 不应登出 -type RefreshResult = 'ok' | 'auth_failed' | 'network_error'; +// - 'banned' 用户被封禁(后端返回 403 + USER_BANNED)→ 提示"账号已被封禁" +type RefreshResult = 'ok' | 'auth_failed' | 'network_error' | 'banned'; + +// OptionalAuth 路径白名单与判断函数见 ./optionalAuthPaths(保持纯函数,便于单测)。 // 判断是否为网络错误(fetch 未收到后端响应)。 // 与"后端明确拒绝(401 等)"区分开,避免网络故障被误判为账号退出。 @@ -238,6 +249,12 @@ class ApiClient { this.navigateToLogin(); throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); } + if (refreshResult === 'banned') { + // 用户被封禁:清登录态并跳登录页,提示"账号已被封禁" + await this.clearToken(); + this.navigateToLogin(); + throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED'); + } // 'network_error':网络故障,保留登录态,沿用旧 token 继续请求 // (旧 token 可能仍有效;若已过期,后端会返回 401,届时再走重试逻辑) const newToken = await this.getToken(); @@ -265,6 +282,18 @@ class ApiClient { // 处理 401 未授权 if (response.status === 401) { + // OptionalAuth 接口(仅 GET):后端检测到携带了过期/失效 token 就会返回 401 + // (不再静默降级为游客)。客户端清掉本地过期 token,以游客身份重试一次。 + // 这是可选认证场景,不应触发 navigateToLogin —— 用户可能是游客或 token 自然过期。 + // + // 仅 GET:写接口(POST/PUT/DELETE)的前缀与 RequireAuth 写接口完全重叠, + // 仅靠 path 无法区分,故写接口统一走下方 RequireAuth 的 refresh 重试流程, + // 避免把"本可 refresh 续期"的用户误判为登出。 + if (shouldRetryAsGuestOn401(method, path) && _retryCount === 0) { + await this.clearToken(); + return this.request(method, path, params, body, _retryCount + 1); + } + if (_retryCount >= 1) { // 已重试过仍 401 → 后端明确拒绝该账号 → 登出 await this.clearToken(); @@ -278,6 +307,12 @@ class ApiClient { this.navigateToLogin(); throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); } + if (refreshResult === 'banned') { + // 用户被封禁:清登录态并跳登录页,提示"账号已被封禁" + await this.clearToken(); + this.navigateToLogin(); + throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED'); + } if (refreshResult === 'network_error') { // 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期, // 保留登录态,向上抛出可重试的网络错误,避免误判登出 @@ -382,13 +417,20 @@ class ApiClient { }); // 后端明确拒绝(refresh token 失效/黑名单)→ 认证失败 + // 但需先判断是否为用户被封禁(403 + error_code=USER_BANNED), + // 单独返回 'banned',让上层给出"账号已被封禁"提示而非通用"登录已过期"。 if (!response.ok) { + if (await this.isBannedResponse(response)) { + return 'banned'; + } return 'auth_failed'; } const data = await response.json(); if (data.code === 0 && data.data?.token) { await this.setToken(data.data.token); + // refresh token rotate:后端返回新的 refresh_token 时存新的; + // 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。 if (data.data.refresh_token || data.data.refreshToken) { await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken); } @@ -396,7 +438,11 @@ class ApiClient { return 'ok'; } - // 业务码非 0:后端明确返回失败 → 认证失败 + // 业务码非 0:同样需先排除 USER_BANNED + if (this.isBannedErrorCode(data)) { + return 'banned'; + } + // 后端明确返回失败 → 认证失败 return 'auth_failed'; } catch (error) { // fetch 抛异常(DNS 失败、断网、超时)→ 网络故障,不应登出 @@ -409,6 +455,24 @@ class ApiClient { } } + // 判断 /auth/refresh 的失败响应是否为"用户被封禁"(403 + error_code=USER_BANNED)。 + // 已 clone response,避免消耗原始 body stream。 + private async isBannedResponse(response: Response): Promise { + try { + const body = await response.json(); + return this.isBannedErrorCode(body); + } catch { + return false; + } + } + + // 判断响应体是否携带 USER_BANNED 错误码。 + private isBannedErrorCode(body: any): boolean { + if (!body || typeof body !== 'object') return false; + const code = String(body.error_code ?? body.errorCode ?? '').toUpperCase(); + return code === 'USER_BANNED'; + } + // 冷启动/刷新token后自动注册设备,方便存量设备接入 private registerDeviceOnRefresh(): void { (async () => { @@ -493,6 +557,12 @@ class ApiClient { this.navigateToLogin(); throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR'); } + if (refreshResult === 'banned') { + // 用户被封禁:清登录态并跳登录页 + await this.clearToken(); + this.navigateToLogin(); + throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED'); + } // 'ok' 或 'network_error':尝试用(可能已刷新的)token 继续 const newToken = await this.getToken(); if (newToken) { diff --git a/src/services/core/errorHandler.ts b/src/services/core/errorHandler.ts index 426be56..ce9d794 100644 --- a/src/services/core/errorHandler.ts +++ b/src/services/core/errorHandler.ts @@ -52,6 +52,10 @@ function classifyError(error: unknown): AppErrorCode { if (isNetworkError(error)) return AppErrorCode.NETWORK_ERROR; if (error instanceof ApiError) { if (error.errorCode === 'AUTH_ERROR' || error.code === 401) return AppErrorCode.AUTH_ERROR; + // USER_BANNED 优先识别:后端封禁用户的 errorCode, + // 复用 FORBIDDEN 分类(语义相近),具体中文提示由调用方按 errorCode 给出。 + // (不在此处新增 BANNED 枚举值,保持改动面最小。) + if (error.errorCode === 'USER_BANNED') return AppErrorCode.FORBIDDEN; if (error.errorCode === 'FORBIDDEN' || error.code === 403) return AppErrorCode.FORBIDDEN; if (error.errorCode === 'NOT_FOUND' || error.code === 404) return AppErrorCode.NOT_FOUND; if (error.errorCode === 'VERIFICATION_REQUIRED') return AppErrorCode.VALIDATION_ERROR; diff --git a/src/services/core/optionalAuthPaths.ts b/src/services/core/optionalAuthPaths.ts new file mode 100644 index 0000000..812b268 --- /dev/null +++ b/src/services/core/optionalAuthPaths.ts @@ -0,0 +1,44 @@ +/** + * OptionalAuth 中间件路径白名单(纯数据,无副作用,便于单测)。 + * + * 与后端 router.go 中走 optionalAuth 中间件的路由保持同步。 + * 这些接口对游客开放,但若客户端携带了过期/失效 token,后端会返回 401 + * (不再静默降级为游客)。客户端策略:401 时清掉本地过期 token,并以游客身份重试一次。 + * + * 注意:/users/me 及其子路径(/users/me/email/verify 等)是 RequireAuth,必须排除。 + */ + +// OptionalAuth 中间件覆盖的路径前缀。 +export const OPTIONAL_AUTH_PATH_PREFIXES = [ + '/posts', // GET /posts, /posts/:id, /posts/search, /posts/suggest, /posts/:id/view, /posts/:id/share, /posts/:id/related, /posts/:id/refs, /posts/:id/ref-click, /posts/:id/vote + '/comments', // GET /comments/... + '/users/search', // 必须放在 /users/ 之前,避免 /users/me 被通配吞掉 + '/users/', // GET /users/:id、/users/:id/posts、/users/:id/favorites + '/trade', // GET /trade, /trade/:id, POST /trade/:id/view +]; + +// 判断请求路径是否走 OptionalAuth 中间件(对游客开放、但携带过期 token 会被拒)。 +// +// /users/me* 是 RequireAuth(/users/me、/users/me/email/verify、/users/me/privacy 等), +// 必须排除,避免把强制认证接口误判为可选认证。 +export function isOptionalAuthPath(path: string): boolean { + // /users/me* 是 RequireAuth,必须排除 + if (path.startsWith('/users/me')) return false; + return OPTIONAL_AUTH_PATH_PREFIXES.some(p => path.startsWith(p)); +} + +// 判断"携带过期 token 触发 401 时,能否按 OptionalAuth 语义以游客身份重试"。 +// +// 策略(保守,避免误伤 RequireAuth 接口): +// - 仅对 GET 请求应用 OptionalAuth 重试。 +// - 这些前缀下的 POST/PUT/DELETE 既有 OptionalAuth 接口(如 /posts/:id/view、 +// /posts/:id/share、/trade/:id/view),也有 RequireAuth 接口(如 /posts/:id/like、 +// /posts/:id/favorite、/trade/:id/favorite)。两者前缀完全重叠,仅靠 path 无法区分。 +// - 若对 RequireAuth 写接口误用 OptionalAuth 重试,会把"本可 refresh 续期成功"的 +// 用户误判为登出(清 token → 游客重试仍 401 → 登出)。为避免该回归, +// POST/PUT/DELETE 一律走 RequireAuth 的 refresh 重试流程(即便个别 OptionalAuth +// POST 接口因此多走一次 refresh,也只影响性能,不影响正确性)。 +export function shouldRetryAsGuestOn401(method: string, path: string): boolean { + if (method.toUpperCase() !== 'GET') return false; + return isOptionalAuthPath(path); +} diff --git a/src/stores/auth/authStore.ts b/src/stores/auth/authStore.ts index 051f96a..8f740ed 100644 --- a/src/stores/auth/authStore.ts +++ b/src/stores/auth/authStore.ts @@ -332,8 +332,9 @@ export const useAuthStore = create()( // 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB) // 返回判别类型: - // - 'user' 成功,继续登录流程 - // - 'auth_failed' 后端明确拒绝(401)→ 清除登录态 + // - 'user' 成功,继续登录流程 + // - 'auth_failed' 后端明确拒绝(401)→ 清除登录态 + // - 'banned' 用户被封禁(403 USER_BANNED)→ 清除登录态 + 提示"账号已被封禁" // - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated), // 等待网络恢复后重试,避免断网即被登出 const result = await authService.fetchCurrentUserFromAPI(); @@ -384,6 +385,17 @@ export const useAuthStore = create()( isLoading: false, error: '网络连接失败,已使用本地登录态', }); + } else if (result.kind === 'banned') { + // 用户被封禁:后端返回 403 USER_BANNED。 + // 清除登录态 + 提示"账号已被封禁,请联系管理员"。 + await clearUserId(); + useSessionStore.getState().setUserId(null); + set({ + isAuthenticated: false, + currentUser: null, + isLoading: false, + error: '账号已被封禁,请联系管理员', + }); } else { // auth_failed:后端明确拒绝,Token 真失效 → 清除登录态 await clearUserId();