后端: 1. 登录注册补齐极验行为验证与跨域入口:gateway 新增 `/user/captcha/register`,登录/注册先做 GeeTest 初始化与二次校验,再进入 user/auth RPC;补充验证码失败/初始化失败/服务不可用响应码,并新增可配置 CORS middleware 适配分域部署。 2. 容器部署配置入口收口:`bootstrap.LoadConfig` 支持 `SMARTFLOW_CONFIG_FILE` 与环境变量覆盖,`config.example.yaml` / `config.docker.yaml` 补齐 geetest 与容器内服务地址,网关新增配置列表解析,便于 compose 场景直接挂载配置启动。 3. LLM outbox 与助手时间线稳定性修正:`cmd/llm` 显式绑定 llm 自身 topic/group,避免误入 agent consumer group;agent timeline 在 Redis 热缓存未落 MySQL 时改用 `seq` 兜底临时 id,避免前端历史回放撞 key。 前端: 4. 认证页接入极验并补齐提交前校验:新增 GeeTest 脚本加载与实例封装,登录/注册面板支持 challenge 初始化、切换面板重挂载、失败提示与提交前校验,认证 API/types 同步透传 geetest 三元组。 5. 前端部署基址与网关对接收口:Axios `baseURL`、Vue Router `history base` 与 Vite `base/dev proxy` 改为读取环境变量,新增 `frontend/.env.example`,支持子路径部署、容器内反向代理和本地联调共存。 6. 助手与工作台展示细节修正:AssistantPanel 历史重建优先使用真实 timeline id、缺失时退回 `seq` 保证消息主键唯一;首页主面板改为纵向可滚动并补底部留白,避免内容截断。 仓库: 7. 整站容器化交付链路补齐并重写说明文档:新增后端/前端 Dockerfile、`.dockerignore`、前端 Nginx 代理、`docker-compose.full.yml`、`.env.full.example` 与镜像打包/导入脚本,README 改写数据库/路由/部署章节,并新增 `docs/容器化部署说明.md` 说明离线镜像分发方案。
146 lines
3.8 KiB
TypeScript
146 lines
3.8 KiB
TypeScript
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||
|
||
import router from '@/router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import type { ApiResponse, TokenPair } from '@/types/api'
|
||
|
||
declare module 'axios' {
|
||
interface AxiosRequestConfig {
|
||
skipAuth?: boolean
|
||
skipRefresh?: boolean
|
||
_retry?: boolean
|
||
}
|
||
|
||
interface InternalAxiosRequestConfig {
|
||
skipAuth?: boolean
|
||
skipRefresh?: boolean
|
||
_retry?: boolean
|
||
}
|
||
}
|
||
|
||
function resolveApiBaseURL() {
|
||
const rawBaseURL = import.meta.env.VITE_API_BASE_URL?.trim() || '/api/v1'
|
||
if (/^https?:\/\//i.test(rawBaseURL)) {
|
||
return rawBaseURL.replace(/\/+$/, '')
|
||
}
|
||
const normalizedPath = `/${rawBaseURL.replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||
return normalizedPath || '/api/v1'
|
||
}
|
||
|
||
const apiBaseURL = resolveApiBaseURL()
|
||
|
||
const http = axios.create({
|
||
baseURL: apiBaseURL,
|
||
timeout: 12000,
|
||
})
|
||
|
||
const refreshHttp = axios.create({
|
||
baseURL: apiBaseURL,
|
||
timeout: 12000,
|
||
})
|
||
|
||
let refreshPromise: Promise<TokenPair> | null = null
|
||
|
||
function getAuthStore() {
|
||
return useAuthStore()
|
||
}
|
||
|
||
async function redirectToAuth() {
|
||
if (router.currentRoute.value.name !== 'auth') {
|
||
await router.push('/auth')
|
||
}
|
||
}
|
||
|
||
// refreshAccessToken 负责把多个并发 401 合并成一次 refresh 请求。
|
||
// 职责边界:
|
||
// 1. 负责读取当前 refresh token,并向后端换发新的 token 对。
|
||
// 2. 不负责决定“哪些请求应该触发 refresh”;这一层由响应拦截器判断。
|
||
// 3. 失败时会清理本地会话并跳回登录页,避免页面继续带着坏 token 重试。
|
||
async function refreshAccessToken() {
|
||
if (refreshPromise) {
|
||
return refreshPromise
|
||
}
|
||
|
||
const authStore = getAuthStore()
|
||
const currentRefreshToken = authStore.refreshToken.trim()
|
||
if (!currentRefreshToken) {
|
||
authStore.clearSession()
|
||
await redirectToAuth()
|
||
throw new Error('缺少 refresh token,请重新登录')
|
||
}
|
||
|
||
refreshPromise = refreshHttp
|
||
.post<ApiResponse<TokenPair>>(
|
||
'/user/refresh-token',
|
||
{
|
||
old_refresh_token: currentRefreshToken,
|
||
},
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
)
|
||
.then((response) => {
|
||
const tokens = response.data.data
|
||
authStore.applyTokenPair(tokens)
|
||
return tokens
|
||
})
|
||
.catch(async (error) => {
|
||
authStore.clearSession()
|
||
await redirectToAuth()
|
||
throw error
|
||
})
|
||
.finally(() => {
|
||
refreshPromise = null
|
||
})
|
||
|
||
return refreshPromise
|
||
}
|
||
|
||
http.interceptors.request.use((config) => {
|
||
const authStore = getAuthStore()
|
||
|
||
// 1. 默认所有业务请求都自动带 access token。
|
||
// 2. 登录/注册/刷新这类公开接口通过 skipAuth 显式跳过,避免误带过期 token。
|
||
if (!config.skipAuth && authStore.accessToken) {
|
||
config.headers.Authorization = `Bearer ${authStore.accessToken}`
|
||
}
|
||
|
||
return config
|
||
})
|
||
|
||
http.interceptors.response.use(
|
||
(response) => response,
|
||
async (error: AxiosError) => {
|
||
const authStore = getAuthStore()
|
||
const originalRequest = error.config as InternalAxiosRequestConfig | undefined
|
||
|
||
// 1. 只对“带请求配置的 401”尝试自动续签。
|
||
// 2. 已经重试过、显式禁用 refresh 的请求,直接走失败兜底,避免死循环。
|
||
if (
|
||
error.response?.status !== 401 ||
|
||
!originalRequest ||
|
||
originalRequest.skipRefresh ||
|
||
originalRequest._retry
|
||
) {
|
||
if (error.response?.status === 401 && !originalRequest?.skipRefresh) {
|
||
authStore.clearSession()
|
||
await redirectToAuth()
|
||
}
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
originalRequest._retry = true
|
||
|
||
try {
|
||
const tokens = await refreshAccessToken()
|
||
originalRequest.headers.Authorization = `Bearer ${tokens.access_token}`
|
||
return http(originalRequest)
|
||
} catch (refreshError) {
|
||
return Promise.reject(refreshError)
|
||
}
|
||
},
|
||
)
|
||
|
||
export default http
|