feat: 双token

This commit is contained in:
2026-06-16 16:16:47 +08:00
parent d9b8567b6d
commit 172c83ea73
8 changed files with 455 additions and 79 deletions

View File

@@ -3,7 +3,7 @@
"info": {
"title": "WebGame 后端接口文档",
"version": "1.0.0",
"description": "WebGame 游戏后端 REST API 接口文档"
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制Access Token30 分钟有效,每次请求携带) + Refresh Token30 天有效,仅用于刷新 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": "根据请求头中的 saTokenAccess 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 TokenUUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
"example": "12345678-1234-1234-1234-123456789abc"
},
"refreshToken": {
"type": "string",
"description": "Refresh Token64 位十六进制随机字符串),有效期 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": "用户不存在",

View File

@@ -25,23 +25,36 @@ export interface RegisterRequest {
}
/**
* 登录/注册响应
* 刷新令牌请求参数
*
* 对应 OpenAPI: RefreshTokenRequest
*/
export interface RefreshTokenRequest {
/** 刷新令牌,登录/注册时返回的 refreshToken 值,不能为空 */
refreshToken: string
}
/**
* 登录/注册/刷新令牌响应
*
* 对应 OpenAPI: LoginResponse
* 采用双 Token 机制Access Token30 分钟) + Refresh Token30 天)
*/
export interface LoginResponse {
/** Sa-Token 认证令牌UUID 格式) */
token: string
/** Access TokenUUID 格式),有效期 30 分钟,后续请求在 Header 中通过 saToken 携带 */
accessToken: string
/** Refresh Token64 位十六进制字符串),有效期 30 天,仅用于刷新 Access Token */
refreshToken: string
}
/**
* 用户登录
*
* 使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌
* 使用用户名和密码进行登录认证,成功返回 Token。
* 该接口无需登录即可调用。
*
* @param req - 登录请求参数
* @returns 包含认证令牌的响应
* @returns 包含 accessToken 和 refreshToken 的响应
*/
export function login(req: LoginRequest) {
return requestClient.post<LoginResponse>('/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<LoginResponse>('/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<LoginResponse>('/account/refresh', req)
}

View File

@@ -63,11 +63,11 @@ class RequestClient {
config.url = resolvePathParams(config.url, reqConfig.pathParams)
}
// 跳过鉴权时不注入 saToken
// 跳过鉴权时不注入 saTokenAccess Token
if (!reqConfig?.skipAuth) {
const accountStore = useAccountStore()
if (accountStore.token) {
config.headers.saToken = accountStore.token
if (accountStore.accessToken) {
config.headers.saToken = accountStore.accessToken
}
}

View File

@@ -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
})
}

View File

@@ -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')
})

View File

@@ -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<AccountState>()
const token = ref('')
/** 账户信息 */
const accountInfo = ref<AccountState | null>(null)
/** Access Token30 分钟有效,每次请求通过 saToken 头携带) */
const accessToken = ref('')
/** Refresh Token30 天有效,仅用于刷新 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
* 成功后更新 accessTokenRefresh 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']
}
}
)

View File

@@ -27,7 +27,7 @@
</n-form-item>
<n-form-item>
<div class="flex flex-1 items-center justify-between">
<n-checkbox>记住账号</n-checkbox>
<n-checkbox v-model:checked="rememberAccount">记住账号</n-checkbox>
<a href="javascript:void(0)" style="color: #2080f0">忘记密码</a>
</div>
</n-form-item>
@@ -53,26 +53,43 @@
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef } from 'vue'
import { reactive, ref, useTemplateRef, onMounted } from 'vue'
import type { FormItemRule, FormRules, FormInst } from 'naive-ui'
import { useMessage } from 'naive-ui'
import { useAccountStore } from '@/stores'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
/** 记住账号的 localStorage key */
const REMEMBER_KEY = 'login_remembered_username'
const router = useRouter()
const route = useRoute()
const accountStore = useAccountStore()
const message = useMessage()
const formRef = useTemplateRef<FormInst>('formRef')
const loginModel = reactive({
username: '',
password: ''
})
const loading = ref(false)
/** 是否记住账号 */
const rememberAccount = ref(false)
/** 页面初始化时恢复已记住的账号 */
onMounted(() => {
const savedUsername = localStorage.getItem(REMEMBER_KEY)
if (savedUsername) {
loginModel.username = savedUsername
rememberAccount.value = true
}
})
const rules: FormRules = {
username: [
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
validator: (_rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入登陆账号')
} else if (value.length < 2) {
@@ -90,7 +107,7 @@ const rules: FormRules = {
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
validator: (_rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入密码')
} else if (value.length < 8) {
@@ -106,16 +123,55 @@ const rules: FormRules = {
]
}
/**
* 获取友好的错误提示信息
* @param error - 捕获的错误对象
* @returns 用户可读的错误文本
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
// 网络错误
if (error.message?.includes('Network Error') || error.message?.includes('网络')) {
return '网络连接失败,请检查网络后重试'
}
return error.message
}
if (typeof error === 'object' && error !== null) {
const err = error as Record<string, unknown>
if (typeof err.message === 'string' && err.message) {
return err.message
}
}
return '登录失败,请稍后重试'
}
/** 用户登录 */
const userLoginFun = () => {
formRef.value?.validate((errors) => {
if (!errors) {
loading.value = true
console.log('登录成功')
// TODO 登录方法
accountStore.token = '123456'
setTimeout(() => {
loading.value = false
}, 1000)
formRef.value?.validate(async (errors) => {
if (errors) return
loading.value = true
try {
await accountStore.login({
username: loginModel.username,
password: loginModel.password
})
// 记住账号:将用户名存入 localStorage
if (rememberAccount.value) {
localStorage.setItem(REMEMBER_KEY, loginModel.username)
} else {
localStorage.removeItem(REMEMBER_KEY)
}
message.success('登录成功')
// 跳转到目标页面(有 redirect 参数则优先跳回原页面,否则去首页)
const redirectPath = (route.query.redirect as string) || '/'
router.push(redirectPath)
} catch (error: unknown) {
message.error(getErrorMessage(error))
} finally {
loading.value = false
}
})
}

View File

@@ -79,7 +79,7 @@
</n-form-item>
<n-form-item>
<n-button :loading="loading" style="width: 100%" type="primary" @click="userRegisterFun"
>登录
>注册
</n-button>
</n-form-item>
<n-form-item>
@@ -94,8 +94,13 @@
<script setup lang="ts">
import { reactive, ref, useTemplateRef, watch } from 'vue'
import { type FormInst, type FormItemRule, type FormRules } from 'naive-ui'
import { type FormInst, type FormItemRule, type FormRules, useMessage } from 'naive-ui'
import { useAccountStore } from '@/stores'
import { useRouter } from 'vue-router'
const router = useRouter()
const accountStore = useAccountStore()
const message = useMessage()
const formRef = useTemplateRef<FormInst>('formRef')
const registerModel = reactive({
username: '',
@@ -112,6 +117,7 @@ watch(
}
)
/** 检测密码强度0-5 级) */
const checkPasswordStrength = (password: string) => {
let strength = 0
if (password.length >= 8) {
@@ -135,7 +141,7 @@ const rules: FormRules = {
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
validator: (_rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入登陆账号')
} else if (value.length < 2) {
@@ -153,7 +159,7 @@ const rules: FormRules = {
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
validator: (_rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入密码')
} else if (value.length < 8) {
@@ -171,7 +177,7 @@ const rules: FormRules = {
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
validator: (_rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请再次输入密码')
}
@@ -186,8 +192,7 @@ const rules: FormRules = {
{
required: true,
trigger: 'change',
validator: (rule: FormItemRule, value: boolean) => {
console.log(value)
validator: (_rule: FormItemRule, value: boolean) => {
if (!value) {
return new Error('请勾选同意协议')
}
@@ -197,15 +202,46 @@ const rules: FormRules = {
]
}
/**
* 获取友好的错误提示信息
* @param error - 捕获的错误对象
* @returns 用户可读的错误文本
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.message?.includes('Network Error') || error.message?.includes('网络')) {
return '网络连接失败,请检查网络后重试'
}
return error.message
}
if (typeof error === 'object' && error !== null) {
const err = error as Record<string, unknown>
if (typeof err.message === 'string' && err.message) {
return err.message
}
}
return '注册失败,请稍后重试'
}
/** 用户注册 */
const userRegisterFun = () => {
formRef.value?.validate((errors) => {
if (!errors) {
loading.value = true
console.log('注册成功')
// TODO 注册方法
setTimeout(() => {
loading.value = false
}, 1000)
formRef.value?.validate(async (errors) => {
if (errors) return
loading.value = true
try {
await accountStore.register({
username: registerModel.username,
password: registerModel.password
})
message.success('注册成功')
// 注册成功后跳转首页
router.push('/')
} catch (error: unknown) {
message.error(getErrorMessage(error))
} finally {
loading.value = false
}
})
}