chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
273ebf4657
101 files changed
+8725
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { clearToken, getToken } from '@/utils/storage'
|
||||
|
||||
/** 后端统一响应信封(见 PROJECT_STANDARDS 第 3 节) */
|
||||
export interface ApiResult<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截:注入 Bearer 令牌
|
||||
service.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/**
|
||||
* 令牌失效时的跳转去重标志。
|
||||
*
|
||||
* 页面初始化时常常并发多个请求,令牌过期会同时触发多次 401;
|
||||
* 没有这个标志就会弹出多条提示并反复跳转。
|
||||
*/
|
||||
let redirecting = false
|
||||
|
||||
function handleUnauthorized(message: string): void {
|
||||
if (redirecting) return
|
||||
redirecting = true
|
||||
|
||||
clearToken()
|
||||
ElMessage.error(message)
|
||||
|
||||
// 用整页跳转而非 router.replace:既避免 request → router → store → request
|
||||
// 的循环依赖,也能顺带清空内存中的应用状态
|
||||
const current = window.location.pathname + window.location.search
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
|
||||
|
||||
window.setTimeout(() => {
|
||||
redirecting = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResult>) => {
|
||||
const body = response.data
|
||||
if (body.code !== 200) {
|
||||
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
|
||||
ElMessage.error(body.message || '请求失败')
|
||||
return Promise.reject(new Error(body.message || '请求失败'))
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResult>) => {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
||||
|
||||
if (status === 401) {
|
||||
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
|
||||
// 因此不会出现「用户输错一次密码就被弹回登录页」的循环。
|
||||
handleUnauthorized(message)
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 发起请求并按统一信封解包。
|
||||
*
|
||||
* 成功时直接返回 `data` 字段,调用方不必层层 `.data`;
|
||||
* 失败时已由拦截器提示并 reject,调用方通常只需 try/catch 做状态回滚。
|
||||
*/
|
||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
const response = await service.request<ApiResult<T>>(config)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export default service
|
||||
Reference in new issue
Block a user