feat(auth): 添加登出功能并优化认证处理

- 添加了登出相关的API端点和常量定义
- 实现了登出功能及密码验证登出逻辑
- 添加了登出会话清理和浏览器存储清除
- 在用户信息模态框中集成了登出按钮
- 添加了登出相关的国际化翻译
- 优化了API客户端中的认证错误处理
- 实现了无效令牌的自动处理机制
- 更新了GitNexus索引统计数据
- 修改了构建输出目录配置
- 清理了不必要的注释和代码
- 调整了移动端头部组件结构
- 优化了游戏历史记录查询逻辑
- 添加了控制台日志用于调试
- 设置默认注册邀请码为D97DBC16
- 在.gitignore中添加构建产物忽略规则
This commit is contained in:
JiaJun
2026-05-29 16:26:35 +08:00
parent dbfe5701aa
commit 15c519a42c
26 changed files with 527 additions and 87 deletions

View File

@@ -2,6 +2,7 @@ import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES,
AUTH_INVALID_TOKEN_CODE,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
@@ -14,6 +15,7 @@ import type { AuthTokenDto } from '@/features/auth/api/types'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleInvalidTokenSession,
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
@@ -90,6 +92,10 @@ function getErrorMessage(response: Response, data: unknown) {
}
async function toApiError(error: unknown) {
if (error instanceof ApiError) {
return error
}
if (error instanceof HTTPError) {
const data = error.data
@@ -186,7 +192,40 @@ function shouldTryRefreshAccessToken(input: string, options?: Options) {
)
}
function isApiEnvelope(value: unknown): value is ApiResponse<unknown> {
return Boolean(
value &&
typeof value === 'object' &&
'code' in value &&
typeof value.code === 'number',
)
}
function getApiEnvelopeMessage(response: ApiResponse<unknown>) {
return 'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected
}
function assertValidAuthEnvelope(data: unknown) {
if (!isApiEnvelope(data) || data.code !== AUTH_INVALID_TOKEN_CODE) {
return
}
handleInvalidTokenSession()
throw new ApiError({
data,
message: getApiEnvelopeMessage(data),
status: 401,
})
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
assertValidAuthEnvelope(response)
if (response.code === 1) {
return response.data
}
@@ -313,6 +352,8 @@ async function request<TResponse>(input: string, options?: Options) {
)
const data = await parseResponseBody(response)
assertValidAuthEnvelope(data)
return data as TResponse
} catch (error) {
if (

View File

@@ -1,5 +1,9 @@
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { queryClient } from '@/lib/query/query-client'
import type { AuthSessionInput, AuthUser } from '@/store/auth'
import { useAuthStore } from '@/store/auth'
import { useModalStore } from '@/store/modal'
export type CurrentUserInitializer = () => Promise<AuthUser | null>
export type RefreshSessionHandler = (
@@ -10,6 +14,28 @@ let currentUserInitializer: CurrentUserInitializer | null = null
let refreshSessionHandler: RefreshSessionHandler | null = null
let authInitializationPromise: Promise<void> | null = null
let refreshSessionPromise: Promise<boolean> | null = null
let lastLoginPromptAt = 0
const LOGIN_PROMPT_DEDUP_MS = 1200
interface ClearAuthenticatedSessionOptions {
clearBrowserStorage?: boolean
}
interface UnauthorizedSessionOptions extends ClearAuthenticatedSessionOptions {
openLoginModal?: boolean
showLoginRequiredToast?: boolean
}
function clearBrowserStorageData() {
if (typeof localStorage !== 'undefined') {
localStorage.clear()
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.clear()
}
}
export function registerCurrentUserInitializer(
initializer: CurrentUserInitializer | null,
@@ -29,8 +55,55 @@ export function isAuthenticated() {
return snapshot.status === 'authenticated' && Boolean(snapshot.accessToken)
}
export function handleUnauthorizedSession() {
export function clearAuthenticatedSession({
clearBrowserStorage = true,
}: ClearAuthenticatedSessionOptions = {}) {
useAuthStore.getState().markUnauthorized()
queryClient.clear()
if (clearBrowserStorage) {
clearBrowserStorageData()
}
}
export function handleUnauthorizedSession({
clearBrowserStorage = false,
openLoginModal = false,
showLoginRequiredToast = false,
}: UnauthorizedSessionOptions = {}) {
clearAuthenticatedSession({ clearBrowserStorage })
if (!openLoginModal && !showLoginRequiredToast) {
return
}
const now = Date.now()
const shouldPrompt = now - lastLoginPromptAt > LOGIN_PROMPT_DEDUP_MS
if (!shouldPrompt) {
return
}
lastLoginPromptAt = now
if (showLoginRequiredToast) {
notify.warning(i18n.t('commonUi.toast.loginRequired'))
}
if (openLoginModal) {
const modalStore = useModalStore.getState()
modalStore.closeAllModals()
modalStore.setModalOpen('desktopLogin', true)
}
}
export function handleInvalidTokenSession() {
handleUnauthorizedSession({
clearBrowserStorage: true,
openLoginModal: true,
showLoginRequiredToast: true,
})
}
export async function initializeAuthSession() {