feat(auth): implement user ban detection and optional auth path handling
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.
This commit is contained in:
@@ -11,6 +11,7 @@ import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO }
|
|||||||
* 当前用户获取结果。
|
* 当前用户获取结果。
|
||||||
* - user:成功拿到用户信息
|
* - user:成功拿到用户信息
|
||||||
* - auth_failed:后端明确拒绝(401 / token 失效)→ 应登出
|
* - auth_failed:后端明确拒绝(401 / token 失效)→ 应登出
|
||||||
|
* - banned:后端返回 403 USER_BANNED(用户被封禁)→ 提示"账号已被封禁"
|
||||||
* - network_error:网络故障(未到达后端)→ 不应登出,应保留登录态等待重试
|
* - network_error:网络故障(未到达后端)→ 不应登出,应保留登录态等待重试
|
||||||
*
|
*
|
||||||
* 引入该类型是为了修复"断网即被登出"的问题:
|
* 引入该类型是为了修复"断网即被登出"的问题:
|
||||||
@@ -20,6 +21,7 @@ import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO }
|
|||||||
export type FetchCurrentUserResult =
|
export type FetchCurrentUserResult =
|
||||||
| { kind: 'user'; user: User }
|
| { kind: 'user'; user: User }
|
||||||
| { kind: 'auth_failed' }
|
| { kind: 'auth_failed' }
|
||||||
|
| { kind: 'banned' }
|
||||||
| { kind: 'network_error' };
|
| { kind: 'network_error' };
|
||||||
|
|
||||||
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
|
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
|
||||||
@@ -235,11 +237,20 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 用户登出(只管 API + Token,DB/缓存清理交给 authStore)
|
// 用户登出(只管 API + Token,DB/缓存清理交给 authStore)
|
||||||
|
//
|
||||||
|
// 后端行为变化:游客调用 /auth/logout 现在返回 401(之前返回 200)。
|
||||||
|
// 客户端策略:401 视为正常路径(用户已处于登出状态),静默吞掉,
|
||||||
|
// 避免在 finally 清 token 之外产生误导性的错误日志。
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/logout');
|
await api.post('/auth/logout');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[AuthService] 登出 API 失败:', error);
|
const code = error?.code ?? 0;
|
||||||
|
if (code === 401) {
|
||||||
|
// 游客调用登出 → 401,属正常情况(用户已是登出状态),静默处理
|
||||||
|
} else {
|
||||||
|
console.error('[AuthService] 登出 API 失败:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await api.clearToken();
|
await api.clearToken();
|
||||||
}
|
}
|
||||||
@@ -262,6 +273,12 @@ class AuthService {
|
|||||||
console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error);
|
console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error);
|
||||||
return { kind: 'network_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 等):认证失败
|
// 后端明确拒绝(401 等):认证失败
|
||||||
const code = error?.code ?? 0;
|
const code = error?.code ?? 0;
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
@@ -279,28 +296,35 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 刷新Token
|
// 刷新Token
|
||||||
|
//
|
||||||
|
// 注意:后端 rotate 后 refresh_token 是一次性使用的,
|
||||||
|
// 成功响应中后端必然返回新的 refresh_token,这里会存新的。
|
||||||
|
// 网络故障时保留旧 token 不清空,等网络恢复后重试。
|
||||||
|
// 非网络错误(如 USER_BANNED、token 失效)向上抛出,由调用方决策。
|
||||||
async refreshToken(): Promise<RefreshTokenResponse | null> {
|
async refreshToken(): Promise<RefreshTokenResponse | null> {
|
||||||
try {
|
try {
|
||||||
const refreshToken = await api.getRefreshToken();
|
const refreshToken = await api.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.post<RefreshTokenResponse>('/auth/refresh', {
|
const response = await api.post<RefreshTokenResponse>('/auth/refresh', {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (response.data.token) {
|
if (response.data.token) {
|
||||||
await api.setToken(response.data.token);
|
await api.setToken(response.data.token);
|
||||||
}
|
}
|
||||||
|
// refresh_token rotate:后端返回新的 refresh_token 时存新的;
|
||||||
|
// 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。
|
||||||
const newRefreshToken = response.data.refresh_token || response.data.refreshToken;
|
const newRefreshToken = response.data.refresh_token || response.data.refreshToken;
|
||||||
if (newRefreshToken) {
|
if (newRefreshToken) {
|
||||||
await api.setRefreshToken(newRefreshToken);
|
await api.setRefreshToken(newRefreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 网络故障:保留 token,等网络恢复后重试,避免误判登出
|
// 网络故障:保留 token,等网络恢复后重试,避免误判登出
|
||||||
@@ -308,9 +332,10 @@ class AuthService {
|
|||||||
console.warn('[AuthService] 刷新Token网络失败(保留登录态):', error);
|
console.warn('[AuthService] 刷新Token网络失败(保留登录态):', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.error('刷新Token失败:', error);
|
// 非网络错误(USER_BANNED、token 失效等):向上抛出,
|
||||||
await api.clearToken();
|
// 由调用方根据 errorCode 决定提示文案与是否登出。
|
||||||
return null;
|
console.error('[AuthService] 刷新Token失败:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
218
src/services/core/__tests__/optionalAuthPaths.test.ts
Normal file
218
src/services/core/__tests__/optionalAuthPaths.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,14 @@ import { Platform } from 'react-native';
|
|||||||
|
|
||||||
import { eventBus } from '@/core/events/EventBus';
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
import { showVerificationModal } from './verification';
|
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
|
// 生产地址 https://withyou.littlelan.cn
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
@@ -76,7 +84,10 @@ interface JwtPayload {
|
|||||||
// - 'ok' 刷新成功
|
// - 'ok' 刷新成功
|
||||||
// - 'auth_failed' 后端明确拒绝(refresh token 失效)→ 可安全登出
|
// - 'auth_failed' 后端明确拒绝(refresh token 失效)→ 可安全登出
|
||||||
// - 'network_error' 网络故障(未收到后端响应)→ 不应登出
|
// - 'network_error' 网络故障(未收到后端响应)→ 不应登出
|
||||||
type RefreshResult = 'ok' | 'auth_failed' | 'network_error';
|
// - 'banned' 用户被封禁(后端返回 403 + USER_BANNED)→ 提示"账号已被封禁"
|
||||||
|
type RefreshResult = 'ok' | 'auth_failed' | 'network_error' | 'banned';
|
||||||
|
|
||||||
|
// OptionalAuth 路径白名单与判断函数见 ./optionalAuthPaths(保持纯函数,便于单测)。
|
||||||
|
|
||||||
// 判断是否为网络错误(fetch 未收到后端响应)。
|
// 判断是否为网络错误(fetch 未收到后端响应)。
|
||||||
// 与"后端明确拒绝(401 等)"区分开,避免网络故障被误判为账号退出。
|
// 与"后端明确拒绝(401 等)"区分开,避免网络故障被误判为账号退出。
|
||||||
@@ -238,6 +249,12 @@ class ApiClient {
|
|||||||
this.navigateToLogin();
|
this.navigateToLogin();
|
||||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||||
}
|
}
|
||||||
|
if (refreshResult === 'banned') {
|
||||||
|
// 用户被封禁:清登录态并跳登录页,提示"账号已被封禁"
|
||||||
|
await this.clearToken();
|
||||||
|
this.navigateToLogin();
|
||||||
|
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
|
||||||
|
}
|
||||||
// 'network_error':网络故障,保留登录态,沿用旧 token 继续请求
|
// 'network_error':网络故障,保留登录态,沿用旧 token 继续请求
|
||||||
// (旧 token 可能仍有效;若已过期,后端会返回 401,届时再走重试逻辑)
|
// (旧 token 可能仍有效;若已过期,后端会返回 401,届时再走重试逻辑)
|
||||||
const newToken = await this.getToken();
|
const newToken = await this.getToken();
|
||||||
@@ -265,6 +282,18 @@ class ApiClient {
|
|||||||
|
|
||||||
// 处理 401 未授权
|
// 处理 401 未授权
|
||||||
if (response.status === 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) {
|
if (_retryCount >= 1) {
|
||||||
// 已重试过仍 401 → 后端明确拒绝该账号 → 登出
|
// 已重试过仍 401 → 后端明确拒绝该账号 → 登出
|
||||||
await this.clearToken();
|
await this.clearToken();
|
||||||
@@ -278,6 +307,12 @@ class ApiClient {
|
|||||||
this.navigateToLogin();
|
this.navigateToLogin();
|
||||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||||
}
|
}
|
||||||
|
if (refreshResult === 'banned') {
|
||||||
|
// 用户被封禁:清登录态并跳登录页,提示"账号已被封禁"
|
||||||
|
await this.clearToken();
|
||||||
|
this.navigateToLogin();
|
||||||
|
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
|
||||||
|
}
|
||||||
if (refreshResult === 'network_error') {
|
if (refreshResult === 'network_error') {
|
||||||
// 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期,
|
// 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期,
|
||||||
// 保留登录态,向上抛出可重试的网络错误,避免误判登出
|
// 保留登录态,向上抛出可重试的网络错误,避免误判登出
|
||||||
@@ -382,13 +417,20 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 后端明确拒绝(refresh token 失效/黑名单)→ 认证失败
|
// 后端明确拒绝(refresh token 失效/黑名单)→ 认证失败
|
||||||
|
// 但需先判断是否为用户被封禁(403 + error_code=USER_BANNED),
|
||||||
|
// 单独返回 'banned',让上层给出"账号已被封禁"提示而非通用"登录已过期"。
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (await this.isBannedResponse(response)) {
|
||||||
|
return 'banned';
|
||||||
|
}
|
||||||
return 'auth_failed';
|
return 'auth_failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.code === 0 && data.data?.token) {
|
if (data.code === 0 && data.data?.token) {
|
||||||
await this.setToken(data.data.token);
|
await this.setToken(data.data.token);
|
||||||
|
// refresh token rotate:后端返回新的 refresh_token 时存新的;
|
||||||
|
// 若后端兼容旧逻辑没返回,则保留旧的(不清空),避免误清登录态。
|
||||||
if (data.data.refresh_token || data.data.refreshToken) {
|
if (data.data.refresh_token || data.data.refreshToken) {
|
||||||
await this.setRefreshToken(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';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务码非 0:后端明确返回失败 → 认证失败
|
// 业务码非 0:同样需先排除 USER_BANNED
|
||||||
|
if (this.isBannedErrorCode(data)) {
|
||||||
|
return 'banned';
|
||||||
|
}
|
||||||
|
// 后端明确返回失败 → 认证失败
|
||||||
return 'auth_failed';
|
return 'auth_failed';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// fetch 抛异常(DNS 失败、断网、超时)→ 网络故障,不应登出
|
// 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<boolean> {
|
||||||
|
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后自动注册设备,方便存量设备接入
|
// 冷启动/刷新token后自动注册设备,方便存量设备接入
|
||||||
private registerDeviceOnRefresh(): void {
|
private registerDeviceOnRefresh(): void {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -493,6 +557,12 @@ class ApiClient {
|
|||||||
this.navigateToLogin();
|
this.navigateToLogin();
|
||||||
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||||
}
|
}
|
||||||
|
if (refreshResult === 'banned') {
|
||||||
|
// 用户被封禁:清登录态并跳登录页
|
||||||
|
await this.clearToken();
|
||||||
|
this.navigateToLogin();
|
||||||
|
throw new ApiError(403, '账号已被封禁,请联系管理员', 'USER_BANNED');
|
||||||
|
}
|
||||||
// 'ok' 或 'network_error':尝试用(可能已刷新的)token 继续
|
// 'ok' 或 'network_error':尝试用(可能已刷新的)token 继续
|
||||||
const newToken = await this.getToken();
|
const newToken = await this.getToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ function classifyError(error: unknown): AppErrorCode {
|
|||||||
if (isNetworkError(error)) return AppErrorCode.NETWORK_ERROR;
|
if (isNetworkError(error)) return AppErrorCode.NETWORK_ERROR;
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
if (error.errorCode === 'AUTH_ERROR' || error.code === 401) return AppErrorCode.AUTH_ERROR;
|
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 === 'FORBIDDEN' || error.code === 403) return AppErrorCode.FORBIDDEN;
|
||||||
if (error.errorCode === 'NOT_FOUND' || error.code === 404) return AppErrorCode.NOT_FOUND;
|
if (error.errorCode === 'NOT_FOUND' || error.code === 404) return AppErrorCode.NOT_FOUND;
|
||||||
if (error.errorCode === 'VERIFICATION_REQUIRED') return AppErrorCode.VALIDATION_ERROR;
|
if (error.errorCode === 'VERIFICATION_REQUIRED') return AppErrorCode.VALIDATION_ERROR;
|
||||||
|
|||||||
44
src/services/core/optionalAuthPaths.ts
Normal file
44
src/services/core/optionalAuthPaths.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -332,8 +332,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB)
|
// 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB)
|
||||||
// 返回判别类型:
|
// 返回判别类型:
|
||||||
// - 'user' 成功,继续登录流程
|
// - 'user' 成功,继续登录流程
|
||||||
// - 'auth_failed' 后端明确拒绝(401)→ 清除登录态
|
// - 'auth_failed' 后端明确拒绝(401)→ 清除登录态
|
||||||
|
// - 'banned' 用户被封禁(403 USER_BANNED)→ 清除登录态 + 提示"账号已被封禁"
|
||||||
// - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated),
|
// - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated),
|
||||||
// 等待网络恢复后重试,避免断网即被登出
|
// 等待网络恢复后重试,避免断网即被登出
|
||||||
const result = await authService.fetchCurrentUserFromAPI();
|
const result = await authService.fetchCurrentUserFromAPI();
|
||||||
@@ -384,6 +385,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: '网络连接失败,已使用本地登录态',
|
error: '网络连接失败,已使用本地登录态',
|
||||||
});
|
});
|
||||||
|
} else if (result.kind === 'banned') {
|
||||||
|
// 用户被封禁:后端返回 403 USER_BANNED。
|
||||||
|
// 清除登录态 + 提示"账号已被封禁,请联系管理员"。
|
||||||
|
await clearUserId();
|
||||||
|
useSessionStore.getState().setUserId(null);
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
currentUser: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: '账号已被封禁,请联系管理员',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// auth_failed:后端明确拒绝,Token 真失效 → 清除登录态
|
// auth_failed:后端明确拒绝,Token 真失效 → 清除登录态
|
||||||
await clearUserId();
|
await clearUserId();
|
||||||
|
|||||||
Reference in New Issue
Block a user