From 172c83ea7339960996d9ee10f9457e0c6a196f90 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Tue, 16 Jun 2026 16:16:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=8Ctoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.json | 130 ++++++++++++++++++++---- src/api/modules/account/login.ts | 41 ++++++-- src/api/request/requestClient.ts | 6 +- src/router/guard.ts | 35 ++++++- src/router/index.ts | 4 +- src/stores/modules/account.ts | 168 +++++++++++++++++++++++++++---- src/views/auth/Login.vue | 84 +++++++++++++--- src/views/auth/Register.vue | 66 +++++++++--- 8 files changed, 455 insertions(+), 79 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 7ccb466..ff147ce 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "WebGame 后端接口文档", "version": "1.0.0", - "description": "WebGame 游戏后端 REST API 接口文档" + "description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制:Access Token(30 分钟有效,每次请求携带) + Refresh Token(30 天有效,仅用于刷新 Access Token)。" }, "servers": [ { @@ -12,15 +12,15 @@ } ], "tags": [ - { "name": "账号", "description": "账号相关接口(登录、注册,无需认证)" }, - { "name": "用户", "description": "用户相关接口(需登录认证)" } + { "name": "账号", "description": "账号相关接口(登录、注册、刷新令牌,无需认证)" }, + { "name": "用户", "description": "用户相关接口(需 Access Token 认证)" } ], "paths": { "/account/login": { "post": { "tags": ["账号"], "summary": "用户登录", - "description": "使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌。该接口无需登录即可调用。", + "description": "使用用户名和密码进行登录认证,成功返回 Access Token 和 Refresh Token。该接口无需登录即可调用。", "operationId": "login", "security": [], "requestBody": { @@ -37,7 +37,7 @@ }, "responses": { "200": { - "description": "登录成功,返回 token", + "description": "登录成功,返回双 Token", "content": { "application/json": { "schema": { @@ -55,7 +55,8 @@ "code": 0, "message": "success", "data": { - "token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + "accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } } } @@ -70,7 +71,7 @@ "post": { "tags": ["账号"], "summary": "用户注册", - "description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回 token。该接口无需登录即可调用。", + "description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回双 Token。该接口无需登录即可调用。", "operationId": "register", "security": [], "requestBody": { @@ -87,7 +88,7 @@ }, "responses": { "200": { - "description": "注册成功,自动登录返回 token", + "description": "注册成功,自动登录返回双 Token", "content": { "application/json": { "schema": { @@ -105,7 +106,58 @@ "code": 0, "message": "success", "data": { - "token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + "accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/account/refresh": { + "post": { + "tags": ["账号"], + "summary": "刷新 Access Token", + "description": "当 Access Token 过期(30 分钟)后,使用 Refresh Token 换取新的 Access Token。Refresh Token 保持不变,仅在满 30 天或重新登录时更新。该接口无需登录即可调用。", + "operationId": "refresh", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RefreshTokenRequest" }, + "example": { + "refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + } + } + } + }, + "responses": { + "200": { + "description": "刷新成功,返回新的 Access Token 和原 Refresh Token", + "content": { + "application/json": { + "schema": { + "allOf": [ + { "$ref": "#/components/schemas/ApiResponse" }, + { + "type": "object", + "properties": { + "data": { "$ref": "#/components/schemas/LoginResponse" } + } + } + ] + }, + "example": { + "code": 0, + "message": "success", + "data": { + "accessToken": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } } } @@ -120,7 +172,7 @@ "get": { "tags": ["用户"], "summary": "获取当前登录用户信息", - "description": "根据请求头中的 saToken 获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。", + "description": "根据请求头中的 saToken(Access Token)获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。", "operationId": "getUserInfo", "security": [{ "saToken": [] }], "responses": { @@ -166,7 +218,7 @@ "type": "apiKey", "in": "header", "name": "saToken", - "description": "Sa-Token 认证令牌。登录/注册成功后返回的 token 值,在后续请求的 Header 中携带。" + "description": "Access Token(登录/注册/刷新接口返回的 accessToken 值)。有效期 30 分钟,过期后使用 /account/refresh 接口获取新 Token。" } }, "schemas": { @@ -232,15 +284,33 @@ } } }, + "RefreshTokenRequest": { + "type": "object", + "description": "刷新令牌请求体", + "required": ["refreshToken"], + "properties": { + "refreshToken": { + "type": "string", + "description": "刷新令牌,登录/注册时返回的 refreshToken 值,不能为空", + "minLength": 1, + "example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + } + } + }, "LoginResponse": { "type": "object", - "description": "登录或注册成功时返回的认证令牌", - "required": ["token"], + "description": "登录、注册或刷新令牌成功时返回的认证令牌对", + "required": ["accessToken", "refreshToken"], "properties": { - "token": { + "accessToken": { "type": "string", - "description": "Sa-Token 认证令牌(UUID 格式)。后续请求需在 Header 中通过 saToken 字段携带此值。", + "description": "Access Token(UUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。", "example": "12345678-1234-1234-1234-123456789abc" + }, + "refreshToken": { + "type": "string", + "description": "Refresh Token(64 位十六进制随机字符串),有效期 30 天。仅用于 /account/refresh 接口换取新的 Access Token。刷新时不变,重新登录时替换。", + "example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } } }, @@ -348,6 +418,22 @@ "data": null } }, + "refreshTokenInvalid": { + "summary": "刷新令牌无效或已过期", + "value": { + "code": 1003, + "message": "刷新令牌无效或已过期", + "data": null + } + }, + "refreshTokenMissing": { + "summary": "缺少刷新令牌", + "value": { + "code": 1004, + "message": "缺少刷新令牌", + "data": null + } + }, "userNotFound": { "summary": "用户不存在", "value": { @@ -361,7 +447,7 @@ } }, "Unauthorized": { - "description": "未登录或 token 无效(HTTP 200 但 code=401)", + "description": "未登录或 Access Token 无效/过期(HTTP 200 但 code=401)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, @@ -450,6 +536,16 @@ "message": "用户名或密码错误", "description": "登录时用户名或密码不匹配" }, + "REFRESH_TOKEN_INVALID": { + "code": 1003, + "message": "刷新令牌无效或已过期", + "description": "Refresh Token 不存在或已过期(30 天),需重新登录" + }, + "REFRESH_TOKEN_MISSING": { + "code": 1004, + "message": "缺少刷新令牌", + "description": "请求体中的 refreshToken 字段为空" + }, "USER_NOT_FOUND": { "code": 2001, "message": "用户不存在", @@ -477,4 +573,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/api/modules/account/login.ts b/src/api/modules/account/login.ts index a81ed0d..0d25a83 100644 --- a/src/api/modules/account/login.ts +++ b/src/api/modules/account/login.ts @@ -25,23 +25,36 @@ export interface RegisterRequest { } /** - * 登录/注册响应 + * 刷新令牌请求参数 + * + * 对应 OpenAPI: RefreshTokenRequest + */ +export interface RefreshTokenRequest { + /** 刷新令牌,登录/注册时返回的 refreshToken 值,不能为空 */ + refreshToken: string +} + +/** + * 登录/注册/刷新令牌响应 * * 对应 OpenAPI: LoginResponse + * 采用双 Token 机制:Access Token(30 分钟) + Refresh Token(30 天) */ export interface LoginResponse { - /** Sa-Token 认证令牌(UUID 格式) */ - token: string + /** Access Token(UUID 格式),有效期 30 分钟,后续请求在 Header 中通过 saToken 携带 */ + accessToken: string + /** Refresh Token(64 位十六进制字符串),有效期 30 天,仅用于刷新 Access Token */ + refreshToken: string } /** * 用户登录 * - * 使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌。 + * 使用用户名和密码进行登录认证,成功返回双 Token。 * 该接口无需登录即可调用。 * * @param req - 登录请求参数 - * @returns 包含认证令牌的响应 + * @returns 包含 accessToken 和 refreshToken 的响应 */ export function login(req: LoginRequest) { return requestClient.post('/account/login', req) @@ -51,12 +64,26 @@ export function login(req: LoginRequest) { * 用户注册 * * 注册新账号。用户名长度 3-32 位,密码长度 6-64 位。 - * 注册成功后自动登录并返回 token。 + * 注册成功后自动登录并返回双 Token。 * 该接口无需登录即可调用。 * * @param req - 注册请求参数 - * @returns 包含认证令牌的响应 + * @returns 包含 accessToken 和 refreshToken 的响应 */ export function register(req: RegisterRequest) { return requestClient.post('/account/register', req) } + +/** + * 刷新 Access Token + * + * 当 Access Token 过期(30 分钟)后,使用 Refresh Token 换取新的 Access Token。 + * Refresh Token 保持不变,仅在满 30 天或重新登录时更新。 + * 该接口无需登录即可调用。 + * + * @param req - 刷新令牌请求参数 + * @returns 包含新 accessToken 和原 refreshToken 的响应 + */ +export function refresh(req: RefreshTokenRequest) { + return requestClient.post('/account/refresh', req) +} diff --git a/src/api/request/requestClient.ts b/src/api/request/requestClient.ts index 77dc528..43ebe57 100644 --- a/src/api/request/requestClient.ts +++ b/src/api/request/requestClient.ts @@ -63,11 +63,11 @@ class RequestClient { config.url = resolvePathParams(config.url, reqConfig.pathParams) } - // 跳过鉴权时不注入 saToken + // 跳过鉴权时不注入 saToken(Access Token) if (!reqConfig?.skipAuth) { const accountStore = useAccountStore() - if (accountStore.token) { - config.headers.saToken = accountStore.token + if (accountStore.accessToken) { + config.headers.saToken = accountStore.accessToken } } diff --git a/src/router/guard.ts b/src/router/guard.ts index 0f4a724..c4888a7 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -1,12 +1,41 @@ import type { Router } from 'vue-router' +import { useAccountStore } from '@/stores' + +/** 无需登录即可访问的路由路径前缀 */ +const PUBLIC_PATHS = ['/auth'] + +/** + * 判断目标路由是否为公开路由(无需登录) + * @param path - 目标路由路径 + */ +function isPublicPath(path: string): boolean { + return PUBLIC_PATHS.some((prefix) => path.startsWith(prefix)) +} /** * 通用路由守卫 + * - 首次导航时验证 token 是否有效 + * - 未登录用户访问非公开路由 → 跳转登录页 + * - 已登录用户访问登录相关页面 → 跳转首页 */ function setupCommonGuard(router: Router) { - // @ts-ignore - router.beforeEach(async (to, from) => { - // TODO: 路由守卫 + router.beforeEach(async (to) => { + const accountStore = useAccountStore() + + // 首次导航时验证 token 有效性(检查持久化的 token 是否过期) + await accountStore.initAuth() + + // 已登录用户访问公开路由(如登录页),直接跳转首页 + if (accountStore.isLoggedIn() && isPublicPath(to.path)) { + return '/' + } + + // 未登录用户访问非公开路由,跳转登录页 + if (!accountStore.isLoggedIn() && !isPublicPath(to.path)) { + // 将原本要访问的路径作为 redirect 参数传递,登录后可跳回 + return { path: '/auth/login', query: { redirect: to.fullPath } } + } + return true }) } diff --git a/src/router/index.ts b/src/router/index.ts index 3bc344d..984be8e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -15,10 +15,10 @@ const router = createRouter({ // 创建路由守卫 createRouterGuard(router) -/** 订阅 401 未授权事件:清除 token 并跳转登录页 */ +/** 订阅 401 未授权事件:清除认证状态并跳转登录页 */ eventBus.on(AppEvents.UNAUTHORIZED, () => { const accountStore = useAccountStore() - accountStore.token = '' + accountStore.logout() router.push('/auth/login') }) diff --git a/src/stores/modules/account.ts b/src/stores/modules/account.ts index ae596ad..20c8e56 100644 --- a/src/stores/modules/account.ts +++ b/src/stores/modules/account.ts @@ -1,37 +1,169 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { + login as loginApi, + register as registerApi, + refresh as refreshApi, + type LoginRequest, + type RegisterRequest +} from '@/api/modules/account' +import { getUserInfo } from '@/api/modules/user' /** 账户信息 */ export interface AccountState { - /** 账户ID */ - id: string - /** 用户名 */ - username: string - /** 年龄 */ - age?: number - /** 性别 */ - sex?: string - /** 手机号 */ - phone?: string - /** 邮箱 */ - email?: string + /** 用户昵称 */ + nickName?: string /** 头像 */ - avatar?: string + avatar?: string | null + /** 个性签名 */ + signature?: string | null + /** 年龄 */ + age?: number | null + /** 性别:1=男,2=女 */ + sex?: number | null /** 生日 */ - birthday?: string + birthday?: string | null } export const useAccountStore = defineStore( 'account', () => { - const accountInfo = ref() - const token = ref('') + /** 账户信息 */ + const accountInfo = ref(null) + /** Access Token(30 分钟有效,每次请求通过 saToken 头携带) */ + const accessToken = ref('') + /** Refresh Token(30 天有效,仅用于刷新 Access Token) */ + const refreshToken = ref('') + /** token 是否已验证有效 */ + const isTokenValid = ref(false) + /** 是否已完成初始验证(防止重复请求) */ + const isInitialized = ref(false) - return { accountInfo, token } + /** + * 判断当前是否已登录 + * token 存在且已验证有效才算已登录 + */ + const isLoggedIn = () => isTokenValid.value + + /** + * 初始化认证状态 + * + * 用持久化的 Access Token 向服务端验证是否仍然有效。 + * 只会在首次调用时真正发请求,后续调用直接跳过。 + * + * - 无 token → 无操作 + * - token 有效 → 拉取用户信息,标记已登录 + * - token 过期 → 尝试用 Refresh Token 刷新,刷新失败则清除 + */ + const initAuth = async () => { + // 已验证过,跳过 + if (isInitialized.value) return + isInitialized.value = true + + // 没有持久化的 Access Token + if (!accessToken.value) return + + try { + await fetchUserInfo() + // 成功获取用户信息,token 有效 + isTokenValid.value = true + } catch { + // Access Token 可能过期,尝试用 Refresh Token 刷新 + if (refreshToken.value) { + try { + await doRefresh() + isTokenValid.value = true + } catch { + // 刷新也失败,清除 + clearAuth() + } + } else { + clearAuth() + } + } + } + + /** + * 用户登录 + * 调用登录 API,成功后存储双 Token 并获取用户信息 + * @param params - 登录请求参数(用户名、密码) + */ + const login = async (params: LoginRequest) => { + const res = await loginApi(params) + accessToken.value = res.data.accessToken + refreshToken.value = res.data.refreshToken + await fetchUserInfo() + isTokenValid.value = true + } + + /** + * 用户注册 + * 调用注册 API,成功后自动登录(存储双 Token 并获取用户信息) + * @param params - 注册请求参数(用户名、密码) + */ + const register = async (params: RegisterRequest) => { + const res = await registerApi(params) + accessToken.value = res.data.accessToken + refreshToken.value = res.data.refreshToken + await fetchUserInfo() + isTokenValid.value = true + } + + /** + * 退出登录 + * 清除令牌、账户信息和验证状态 + */ + const logout = () => { + clearAuth() + } + + /** + * 获取当前登录用户信息 + * 需要先有 Access Token 才能调用 + */ + const fetchUserInfo = async () => { + const res = await getUserInfo() + accountInfo.value = res.data + } + + /** + * 使用 Refresh Token 刷新 Access Token + * 成功后更新 accessToken,Refresh Token 不变(由服务端行为决定) + */ + const doRefresh = async () => { + const res = await refreshApi({ refreshToken: refreshToken.value }) + accessToken.value = res.data.accessToken + // 如果服务端返回了新的 refreshToken 则更新 + if (res.data.refreshToken) { + refreshToken.value = res.data.refreshToken + } + await fetchUserInfo() + } + + /** 清除所有认证状态 */ + const clearAuth = () => { + accessToken.value = '' + refreshToken.value = '' + accountInfo.value = null + isTokenValid.value = false + isInitialized.value = false + } + + return { + accountInfo, + accessToken, + refreshToken, + isLoggedIn, + initAuth, + login, + register, + logout, + fetchUserInfo + } }, { persist: { - pick: ['accountInfo', 'token'] + pick: ['accessToken', 'refreshToken'] } } ) diff --git a/src/views/auth/Login.vue b/src/views/auth/Login.vue index 2f7f6be..839f416 100644 --- a/src/views/auth/Login.vue +++ b/src/views/auth/Login.vue @@ -27,7 +27,7 @@
- 记住账号 + 记住账号 忘记密码?
@@ -53,26 +53,43 @@