From fb6d18b4f5438d68104d0a8081acf035780a61d3 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Tue, 25 Nov 2025 00:58:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84HTTP=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9E=B6=E6=9E=84=E5=92=8C=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心改进: - HTTP客户端:工厂函数模式,支持自定义拦截器和401/403处理 - 认证服务:函数式实现,消除this绑定问题,支持业务码+HTTP状态码双通道 - Token管理:简化为直接实例导出,移除bind()和箭头函数包装 - 路由守卫:优化逻辑,移除冗余代码,更简洁易维护 技术亮点: - 统一401/403错误处理(业务code和HTTP status双检查) - 自动刷新token并重试请求,保留自定义拦截器 - 分层清晰:clientAxios (Mono) -> http (应用) -> AuthService - 支持扩展:业务代码可创建自定义HTTP实例并添加拦截器 文件变更: - 新增 AuthService.js (函数式) 和 Login.vue - 重构 http.js、token-manager.js、router/index.js - 删除 TokenInput.vue、utils/auth.js 等冗余文件 - 更新所有API调用点使用直接实例导入 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 6 +- .cursor/rules/vue.md | 1 + frontend/api/axios/client.js | 7 +- frontend/app/web-gold/src/App.vue | 8 +- frontend/app/web-gold/src/api/auth.js | 7 +- frontend/app/web-gold/src/api/chat.js | 5 +- frontend/app/web-gold/src/api/common.js | 9 +- frontend/app/web-gold/src/api/http.js | 244 ++++----- .../web-gold/src/components/LoginModal.vue | 5 +- .../web-gold/src/components/TokenInput.vue | 102 ---- frontend/app/web-gold/src/router/index.js | 91 ++-- .../app/web-gold/src/services/AuthService.js | 232 +++++++++ frontend/app/web-gold/src/stores/user.js | 18 +- frontend/app/web-gold/src/utils/auth.js | 54 -- frontend/app/web-gold/src/utils/streamChat.js | 4 +- .../app/web-gold/src/views/auth/Login.vue | 488 ++++++++++++++++++ .../src/views/content-style/Copywriting.vue | 15 + .../web-gold/src/views/trends/Forecast.vue | 15 + frontend/config/api.js | 2 +- frontend/hooks/web/README.md | 161 ------ frontend/hooks/web/useUserInfo.js | 8 +- frontend/utils/README.md | 108 ---- frontend/utils/token-manager.js | 405 ++++++++------- 混剪系统实现文档.md | 351 ------------- 24 files changed, 1148 insertions(+), 1198 deletions(-) delete mode 100644 frontend/app/web-gold/src/components/TokenInput.vue create mode 100644 frontend/app/web-gold/src/services/AuthService.js delete mode 100644 frontend/app/web-gold/src/utils/auth.js create mode 100644 frontend/app/web-gold/src/views/auth/Login.vue delete mode 100644 frontend/hooks/web/README.md delete mode 100644 frontend/utils/README.md delete mode 100644 混剪系统实现文档.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c48dc61ff6..dc63368ede 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,11 @@ "permissions": { "allow": [ "Bash(find:*)", - "Bash(mvn:*)" + "Bash(mvn:*)", + "Bash(node -e \"\ntry {\n const tokenManager = require(''./src/@gold/utils/token-manager.js'');\n console.log(''✅ Default import works:'', typeof tokenManager.getToken);\n \n const { getAccessToken, getDevToken } = require(''./src/@gold/utils/token-manager.js'');\n console.log(''✅ Named import works:'', typeof getAccessToken, typeof getDevToken);\n} catch (e) {\n console.error(''❌ Import failed:'', e.message);\n}\n\")", + "Bash(cat:*)", + "Bash(node -e:*)", + "Bash(git add:*)" ], "deny": [], "ask": [] diff --git a/.cursor/rules/vue.md b/.cursor/rules/vue.md index cd64f4b68e..cd21904e15 100644 --- a/.cursor/rules/vue.md +++ b/.cursor/rules/vue.md @@ -16,6 +16,7 @@ globs: **/*.vue, **/*.ts, components/**/* - 使用标准的 emit 声明 - 保持模板逻辑简洁 - 优先使用template 语法,而不是函数组件 +- 优先使用函数或者hook,而不是类 ## 组合式 API - 正确使用 ref 与 reactive diff --git a/frontend/api/axios/client.js b/frontend/api/axios/client.js index 60da4858b6..eed197a60b 100644 --- a/frontend/api/axios/client.js +++ b/frontend/api/axios/client.js @@ -4,7 +4,8 @@ */ import axios from 'axios' -import { getAuthHeader, clearAllTokens } from '@gold/utils/token-manager' +// 直接使用实例(最简单、最可靠) +import tokenManager from '@gold/utils/token-manager' /** * 不需要 token 的接口白名单 @@ -37,7 +38,7 @@ function handle401Error() { isHandling401 = true try { - clearAllTokens() + tokenManager.clearTokens() } catch (e) { console.error('清空 token 失败:', e) } @@ -85,7 +86,7 @@ export function createClientAxios(options = {}) { // 添加 Authorization header const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '') if (needToken) { - const authHeader = getAuthHeader() + const authHeader = tokenManager.getAuthHeader() if (authHeader) { config.headers.Authorization = authHeader } diff --git a/frontend/app/web-gold/src/App.vue b/frontend/app/web-gold/src/App.vue index f9ec623447..f8b3c7cb6b 100644 --- a/frontend/app/web-gold/src/App.vue +++ b/frontend/app/web-gold/src/App.vue @@ -6,7 +6,7 @@ import TopNav from './components/TopNav.vue' import { theme } from 'ant-design-vue' import SvgSprite from '@/components/icons/SvgSprite.vue' import { useUserStore } from '@/stores/user' -import { getToken } from '@gold/utils/token-manager' +import tokenManager from '@gold/utils/token-manager' function readCssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || undefined @@ -40,15 +40,15 @@ onMounted(async () => { // 检查登录状态:如果有token但store中未标记为登录,则恢复登录状态 const userStore = useUserStore() - + // 等待store从本地存储恢复完成(最多等待500ms) let waitCount = 0 while (!userStore.isHydrated && waitCount < 50) { await new Promise(resolve => setTimeout(resolve, 10)) waitCount++ } - - const token = getToken() + + const token = tokenManager.getAccessToken() if (token) { // 如果有token但未登录,可能是刷新页面,需要恢复登录状态 if (!userStore.isLoggedIn) { diff --git a/frontend/app/web-gold/src/api/auth.js b/frontend/app/web-gold/src/api/auth.js index 1ba5fc71db..59b1410667 100644 --- a/frontend/app/web-gold/src/api/auth.js +++ b/frontend/app/web-gold/src/api/auth.js @@ -1,6 +1,7 @@ import api from '@/api/http' -import { setToken, getRefreshToken } from '@/utils/auth' +// 直接使用实例(最简单、最可靠) +import tokenManager from '@gold/utils/token-manager' // 使用公共配置 import { API_BASE } from '@gold/config/api' @@ -12,7 +13,7 @@ const SERVER_BASE = API_BASE.APP_MEMBER */ function saveTokens(info) { if (info?.accessToken || info?.refreshToken) { - setToken({ + tokenManager.setTokens({ accessToken: info.accessToken || '', refreshToken: info.refreshToken || '', }) @@ -118,7 +119,7 @@ export async function loginBySms(mobile, code) { * @returns {Promise} data.data: { accessToken, refreshToken, expiresTime, userInfo } */ export async function refreshToken() { - const rt = getRefreshToken(); + const rt = tokenManager.getRefreshToken(); if (!rt) throw new Error('缺少 refresh_token'); // refreshToken 作为 URL 查询参数传递 const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } }); diff --git a/frontend/app/web-gold/src/api/chat.js b/frontend/app/web-gold/src/api/chat.js index 58793096da..8a98edc239 100644 --- a/frontend/app/web-gold/src/api/chat.js +++ b/frontend/app/web-gold/src/api/chat.js @@ -1,6 +1,7 @@ import request from '@/api/http' import { fetchEventSource } from '@microsoft/fetch-event-source' -import { getAccessToken } from '@/utils/auth' +// 直接使用 tokenManager 实例(最简单、最可靠) +import tokenManager from '@gold/utils/token-manager' // 使用公共配置 import { API_BASE } from '@gold/config/api' @@ -32,7 +33,7 @@ export const ChatMessageApi = { onClose, attachmentUrls = [] } = options || {} - const token = getAccessToken() + const token = tokenManager.getAccessToken() let retryCount = 0 const maxRetries = 0 // 禁用自动重试 diff --git a/frontend/app/web-gold/src/api/common.js b/frontend/app/web-gold/src/api/common.js index 43d6b84376..e7fa95916a 100644 --- a/frontend/app/web-gold/src/api/common.js +++ b/frontend/app/web-gold/src/api/common.js @@ -4,7 +4,8 @@ */ import { fetchEventSource } from '@microsoft/fetch-event-source' -import { getAuthHeader } from '@gold/utils/token-manager' +// 直接使用实例(最简单、最可靠) +import tokenManager from '@gold/utils/token-manager' import { TikHubService } from '@gold/api/services' import { API_BASE } from '@gold/config/api' @@ -43,9 +44,9 @@ export const CommonService = { onError, onClose } = options || {} - - const authHeader = getAuthHeader() - + + const authHeader = tokenManager.getAuthHeader() + let retryCount = 0 const maxRetries = 0 diff --git a/frontend/app/web-gold/src/api/http.js b/frontend/app/web-gold/src/api/http.js index dd5ff4a6be..47ec5b6b13 100644 --- a/frontend/app/web-gold/src/api/http.js +++ b/frontend/app/web-gold/src/api/http.js @@ -1,168 +1,110 @@ /** * 应用层 HTTP 客户端 - * 使用 mono 级别的 axios 实例,添加应用特定的 401 处理 + * 支持自定义拦截器的可扩展版本 + * - 提供 createHttpClient 工厂函数 + * - 业务代码可以创建自己的 http 实例并添加拦截器 */ -import { message } from 'ant-design-vue' -import { clearAllTokens, getRefreshToken } from '@gold/utils/token-manager' -import { useUserStore } from '@/stores/user' import { createClientAxios } from '@gold/api/axios/client' -import { refreshToken } from '@/api/auth' - -// 刷新 token 的状态管理 -let isRefreshing = false -let refreshPromise = null +import authService from '@/services/AuthService' /** - * 处理 403 禁止访问错误(应用层特定逻辑) - * 先尝试刷新 token,如果失败或没有 refresh token 才提示用户 + * 创建应用层 HTTP 客户端实例 + * @param {Object} options - 配置选项 + * @param {Function} options.on401 - 401 错误处理回调 + * @param {Function} options.on403 - 403 错误处理回调 + * @returns {AxiosInstance} HTTP 客户端实例 */ -async function handleApp403Error() { - // 避免重复处理 - if (handleApp403Error.processed) { - return - } - - handleApp403Error.processed = true - - try { - // 检查是否有 refresh token - const refreshTokenValue = getRefreshToken() - - if (refreshTokenValue) { - // 如果有 refresh token,尝试刷新 - try { - // 如果正在刷新,等待刷新完成 - if (isRefreshing && refreshPromise) { - await refreshPromise - handleApp403Error.processed = false - return - } - - // 开始刷新 token - isRefreshing = true - refreshPromise = refreshToken() - - await refreshPromise - - // 刷新成功,重置状态 - isRefreshing = false - refreshPromise = null - handleApp403Error.processed = false - - // 刷新成功,不提示用户(静默刷新) - return - } catch (refreshError) { - // 刷新失败,清除状态 - isRefreshing = false - refreshPromise = null - console.error('刷新 token 失败:', refreshError) - - // 刷新失败才提示用户 - message.warning('登录状态已过期,请重新登录', 3) - handleApp403Error.processed = false - return +export function createHttpClient(options = {}) { + const { on401, on403 } = options + + const httpClient = createClientAxios({ + baseURL: '/', + timeout: 180000, + on401: (error) => { + if (on401) { + on401(error) + } else { + // 默认使用 AuthService 处理,并传入 httpClient 确保拦截器生效 + authService.handleAuthError( + error, + () => window.location.href = '/login', + { httpClient } // 关键:传入 HTTP 实例 + ) } - } else { - // 没有 refresh token,提示用户 - message.warning('登录状态已过期,请重新登录', 3) - handleApp403Error.processed = false - } - } catch (e) { - console.error('处理 403 错误失败:', e) - handleApp403Error.processed = false - } + }, + on403: (error) => { + if (on403) { + on403(error) + } else { + // 默认使用 AuthService 处理,并传入 httpClient 确保拦截器生效 + authService.handleAuthError( + error, + () => window.location.href = '/login', + { httpClient } // 关键:传入 HTTP 实例 + ) + } + }, + }) + + return httpClient } /** - * 处理 401 未授权错误(应用层特定逻辑) - * 先尝试刷新 token,如果失败或没有 refresh token 才退出登录 + * 默认的 HTTP 客户端实例 + * 业务代码可以: + * 1. 直接使用 http 实例 + * 2. 调用 createHttpClient() 创建自定义实例 + * 3. 在自定义实例上添加拦截器 */ -async function handleApp401Error() { - // 避免重复处理 - if (handleApp401Error.processed) { - return - } - - handleApp401Error.processed = true - - try { - // 检查是否有 refresh token - const refreshTokenValue = getRefreshToken() - - if (refreshTokenValue) { - // 如果有 refresh token,尝试刷新 - try { - // 如果正在刷新,等待刷新完成 - if (isRefreshing && refreshPromise) { - await refreshPromise - handleApp401Error.processed = false - return - } - - // 开始刷新 token - isRefreshing = true - refreshPromise = refreshToken() - - await refreshPromise - - // 刷新成功,重置状态 - isRefreshing = false - refreshPromise = null - handleApp401Error.processed = false - - // 刷新成功,不提示用户(静默刷新) - return - } catch (refreshError) { - // 刷新失败,清除状态 - isRefreshing = false - refreshPromise = null - console.error('刷新 token 失败:', refreshError) - - // 继续执行退出登录逻辑 - } - } - - // 没有 refresh token 或刷新失败,执行退出登录 - try { - clearAllTokens() - } catch (e) { - console.error('清空 token 失败:', e) - } - - try { - const userStore = useUserStore() - userStore.logout() - } catch (e) { - console.error('退出登录失败:', e) - } - - setTimeout(() => { - message.warning('登录已过期,请重新登录', 3) - }, 100) - - } catch (e) { - console.error('处理 401 错误失败:', e) - } finally { - setTimeout(() => { - handleApp401Error.processed = false - }, 2000) - } -} - -/** - * 创建应用层 HTTP 客户端 - * 基于 mono 级别的 axios 实例,添加应用特定的错误处理 - */ -const http = createClientAxios({ - baseURL: '/', - timeout: 180000, - on401: handleApp401Error, - on403: handleApp403Error, -}) - -// 注意:403 处理已在 createClientAxios 的响应拦截器中通过 on403 回调处理 +const http = createHttpClient() export default http +/** + * 使用示例: + * + * 1. 直接使用默认实例 + * import http from '@/api/http' + * await http.post('/api/data') + * + * 2. 创建自定义实例并添加拦截器 + * import { createHttpClient } from '@/api/http' + * + * const myHttp = createHttpClient() + * myHttp.interceptors.request.use((config) => { + * // 添加自定义请求头 + * config.headers['X-Custom-Header'] = 'value' + * return config + * }) + * await myHttp.post('/api/data') + * + * 3. 自定义 401 处理 + * import { createHttpClient } from '@/api/http' + * + * const myHttp = createHttpClient({ + * on401: (error) => { + * // 自定义 401 处理逻辑 + * console.log('自定义 401 处理') + * } + * }) + * + * 4. 高级用法:自动刷新 token 并重试请求 + * import { createHttpClient } from '@/api/http' + * + * const myHttp = createHttpClient() + * + * try { + * const response = await myHttp.post('/api/data') + * console.log(response.data) + * } catch (error) { + * // 如果是 401 且刷新失败,会自动调用 onAuthFailed 回调 + * console.error('请求失败:', error.message) + * } + * + * 注意:当 401 发生时,AuthService 会自动尝试刷新 token。 + * 如果刷新成功,会使用相同的 http 实例重新发起请求, + * 确保自定义拦截器被正确应用。 + */ + diff --git a/frontend/app/web-gold/src/components/LoginModal.vue b/frontend/app/web-gold/src/components/LoginModal.vue index 729c477d72..9aa0df80ee 100644 --- a/frontend/app/web-gold/src/components/LoginModal.vue +++ b/frontend/app/web-gold/src/components/LoginModal.vue @@ -3,7 +3,8 @@ import { ref, computed, onUnmounted } from 'vue' import { message } from 'ant-design-vue' import { useUserStore } from '@/stores/user' import authApi, { SMS_SCENE, SMS_TEMPLATE_CODE } from '@/api/auth' -import { setToken } from '@/utils/auth' +// 直接使用实例(最简单、最可靠) +import tokenManager from '@gold/utils/token-manager' defineProps({ open: { type: Boolean, default: false }, @@ -213,7 +214,7 @@ async function handleSubmit() { async function handleLoginSuccess(info) { if (info?.accessToken) { - setToken({ accessToken: info.accessToken, refreshToken: info.refreshToken }) + tokenManager.setTokens({ accessToken: info.accessToken, refreshToken: info.refreshToken }) } const userInfo = info?.userInfo || {} await userStore.loginWithPhone({ diff --git a/frontend/app/web-gold/src/components/TokenInput.vue b/frontend/app/web-gold/src/components/TokenInput.vue deleted file mode 100644 index 1fb32b2b92..0000000000 --- a/frontend/app/web-gold/src/components/TokenInput.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - diff --git a/frontend/app/web-gold/src/router/index.js b/frontend/app/web-gold/src/router/index.js index 0e8812da7d..c1cd13fedf 100644 --- a/frontend/app/web-gold/src/router/index.js +++ b/frontend/app/web-gold/src/router/index.js @@ -1,12 +1,21 @@ import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/user' -import { getToken } from '@gold/utils/token-manager' +import tokenManager from '@gold/utils/token-manager' const routes = [ { path: '/', redirect: '/content-style/benchmark' }, + // 登录页面 + { + path: '/login', + name: '登录', + component: () => import('../views/auth/Login.vue'), + meta: { + requiresAuth: false + } + }, // { path: '/home', name: '首页', component: () => import('../views/home/Home.vue') }, { path: '/content-style', @@ -53,7 +62,10 @@ const routes = [ children: [ { path: '', redirect: '/system/style-settings' }, { path: 'style-settings', name: '风格设置', component: () => import('../views/system/StyleSettings.vue') }, - ] + ], + meta: { + requiresAuth: true + } }, { path: '/realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') }, { path: '/capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') }, @@ -67,57 +79,46 @@ const router = createRouter({ routes, }) -// 用户信息初始化标志(确保只初始化一次) -let userInfoInitialized = false - -/** - * 路由导航守卫:初始化用户信息 + 登录验证 - * 在首次路由跳转时,如果已登录(有 token),则获取用户信息 - * 如果未登录访问系统相关路由,则重定向到首页 - */ +// 路由守卫 router.beforeEach(async (to, from, next) => { const userStore = useUserStore() - - // 只在首次路由跳转时初始化用户信息 - if (!userInfoInitialized) { - userInfoInitialized = true - - const token = getToken() - if (token) { - try { - // 如果 store 中已标记为登录,则获取用户信息 - if (userStore.isLoggedIn) { - userStore.fetchUserInfo() - } else { - // 如果有 token 但 store 中未标记为登录,可能是刷新页面 - // 先标记为已登录,然后获取用户信息 - userStore.isLoggedIn = true - userStore.fetchUserInfo() - } - } catch (error) { - console.error('初始化用户信息失败:', error) - // 不阻止路由跳转,继续执行 - } - } - } - - // 检查访问系统相关路由时是否已登录 - if (to.path.startsWith('/system')) { - // 等待 store 从本地存储恢复完成(最多等待500ms) + + // 等待 Pinia store 恢复完成(最多等待 500ms) + if (to.meta.requiresAuth) { let waitCount = 0 while (!userStore.isHydrated && waitCount < 50) { await new Promise(resolve => setTimeout(resolve, 10)) waitCount++ } - - // 如果未登录,重定向到首页 - if (!userStore.isLoggedIn) { - next({ path: '/content-style/benchmark', replace: true }) - return - } } - - // 继续路由跳转 + + // 检查是否已登录(通过 token 是否有效) + const isAuthenticated = tokenManager.isLoggedIn() + + // 路由访问控制 + if (to.meta.requiresAuth && !isAuthenticated) { + // 需要认证但未登录,跳转到登录页并记录当前路径 + next({ + path: '/login', + query: { redirect: to.fullPath } + }) + return + } + + // 已登录用户访问登录页,重定向到首页 + if (to.path === '/login' && isAuthenticated) { + next({ path: '/content-style/benchmark', replace: true }) + return + } + + // 首次访问且已登录时,同步用户信息到 store + if (isAuthenticated && !userStore.isLoggedIn) { + userStore.isLoggedIn = true + userStore.fetchUserInfo().catch(error => { + console.error('初始化用户信息失败:', error) + }) + } + next() }) diff --git a/frontend/app/web-gold/src/services/AuthService.js b/frontend/app/web-gold/src/services/AuthService.js new file mode 100644 index 0000000000..a85428c97c --- /dev/null +++ b/frontend/app/web-gold/src/services/AuthService.js @@ -0,0 +1,232 @@ +/** + * 认证服务 - 封装所有认证相关业务逻辑 + * 提供登录、登出、刷新token等方法 + * 函数式实现,无 this 绑定问题 + */ + +import { clientAxios } from '@gold/api/axios/client' +import tokenManager from '@gold/utils/token-manager' + +// 刷新token的锁,避免并发刷新 +let isRefreshing = false +let refreshPromise = null + +/** + * 手机号+验证码登录 + */ +async function loginBySms(mobile, code) { + try { + const { data } = await clientAxios.post('/api/member/auth/sms-login', { mobile, code }) + const tokenInfo = data || {} + + // 保存token + tokenManager.setTokens({ + accessToken: tokenInfo.accessToken || '', + refreshToken: tokenInfo.refreshToken || '', + expiresIn: tokenInfo.expiresTime ? Math.floor(tokenInfo.expiresTime / 1000) : 7200, + tokenType: 'Bearer' + }) + + return tokenInfo + } catch (error) { + console.error('短信登录失败:', error) + throw error + } +} + +/** + * 账号密码登录 + */ +async function loginByPassword(mobile, password) { + try { + const { data } = await clientAxios.post('/api/member/auth/login', { mobile, password }) + const tokenInfo = data || {} + + tokenManager.setTokens({ + accessToken: tokenInfo.accessToken || '', + refreshToken: tokenInfo.refreshToken || '', + expiresIn: tokenInfo.expiresTime ? Math.floor(tokenInfo.expiresTime / 1000) : 7200, + tokenType: 'Bearer' + }) + + return tokenInfo + } catch (error) { + console.error('密码登录失败:', error) + throw error + } +} + +/** + * 执行实际刷新token的逻辑 + */ +async function doRefreshToken(refreshToken) { + const { data } = await clientAxios.post('/api/member/auth/refresh-token', null, { + params: { refreshToken } + }) + const tokenInfo = data || {} + + tokenManager.setTokens({ + accessToken: tokenInfo.accessToken || '', + refreshToken: tokenInfo.refreshToken || '', + expiresIn: tokenInfo.expiresTime ? Math.floor(tokenInfo.expiresTime / 1000) : 7200, + tokenType: 'Bearer' + }) + + return tokenInfo +} + +/** + * 刷新token + * 使用锁机制防止并发刷新 + */ +async function refreshToken() { + // 如果正在刷新,返回已有的刷新Promise + if (isRefreshing && refreshPromise) { + return refreshPromise + } + + const refreshTokenValue = tokenManager.getRefreshToken() + if (!refreshTokenValue) { + throw new Error('缺少refreshToken,无法刷新') + } + + try { + isRefreshing = true + refreshPromise = doRefreshToken(refreshTokenValue) + const result = await refreshPromise + return result + } finally { + isRefreshing = false + refreshPromise = null + } +} + +/** + * 发送短信验证码 + */ +async function sendSmsCode(mobile, scene = 1) { + const { data } = await clientAxios.post('/api/member/auth/send-sms-code', { + mobile, + scene + }) + return data +} + +/** + * 登出 + */ +async function logout() { + try { + // 清除token + tokenManager.clearTokens() + + // 跳转到登录页 + window.location.href = '/login' + } catch (error) { + console.error('登出失败:', error) + // 即使出错也要清除token + tokenManager.clearTokens() + window.location.href = '/login' + } +} + +/** + * 检查是否已登录 + */ +function isAuthenticated() { + return tokenManager.isLoggedIn() +} + +/** + * 获取当前用户信息 + */ +async function getCurrentUser() { + if (!isAuthenticated()) { + return null + } + + try { + const { data } = await clientAxios.get('/api/member/user/get') + return data || null + } catch (error) { + console.error('获取用户信息失败:', error) + return null + } +} + +/** + * 处理401/403错误 + * @param {Error} error - axios错误对象 + * @param {Function} onAuthFailed - 认证失败回调 + * @param {Object} options - 额外选项 + * @param {AxiosInstance} options.httpClient - 可选的HTTP客户端实例,用于重新发起请求 + */ +async function handleAuthError(error, onAuthFailed, options = {}) { + const status = error?.response?.status + const data = error?.response?.data + const code = data?.code + const originalRequest = error.config + const { httpClient } = options + + // 统一检查是否为 401 (token无效或过期) + const is401 = (code === 401) || (status === 401) + + // 统一检查是否为 403 (没有权限) + const is403 = (code === 403) || (status === 403) + + // 处理 401 错误:尝试刷新 token + if (is401) { + try { + // 刷新 token + await refreshToken() + + // 刷新成功,重新发起原请求(如果提供了 httpClient) + if (originalRequest && httpClient) { + originalRequest.headers['Authorization'] = tokenManager.getAuthHeader() + return httpClient(originalRequest) + } + + return null + } catch (refreshError) { + console.error('刷新token失败:', refreshError) + if (typeof onAuthFailed === 'function') { + onAuthFailed() + } + throw error + } + } + + // 处理 403 错误:直接调用回调 + if (is403) { + if (typeof onAuthFailed === 'function') { + onAuthFailed() + } + throw error + } + + // 其他错误直接抛出 + throw error +} + +export default { + loginBySms, + loginByPassword, + refreshToken, + sendSmsCode, + logout, + isAuthenticated, + getCurrentUser, + handleAuthError +} + +// 导出便捷方法(解构赋值,不会有 this 问题) +export { + loginBySms, + loginByPassword, + refreshToken, + sendSmsCode, + logout, + isAuthenticated, + getCurrentUser, + handleAuthError +} diff --git a/frontend/app/web-gold/src/stores/user.js b/frontend/app/web-gold/src/stores/user.js index b19b0fe6db..ef4d905e5c 100644 --- a/frontend/app/web-gold/src/stores/user.js +++ b/frontend/app/web-gold/src/stores/user.js @@ -1,7 +1,8 @@ import { ref, computed, watch } from 'vue' import { defineStore } from 'pinia' import { getJSON, setJSON, remove } from '@/utils/storage' -import { clearAllTokens } from '@gold/utils/token-manager' +// 直接使用实例(最简单、最可靠) +import tokenManager from '@gold/utils/token-manager' // 本地持久化的 key const STORAGE_KEY = 'user_store_v1' @@ -115,12 +116,13 @@ export const useUserStore = defineStore('user', () => { try { // 使用公共 hook 获取用户信息 const { getUserInfo } = await import('@gold/hooks/web/useUserInfo') - const { getToken } = await import('@gold/utils/token-manager') - + // 导入 tokenManager 获取 token + const tokenManager = (await import('@gold/utils/token-manager')).default + const userInfo = await getUserInfo({ - getToken, + getToken: () => tokenManager.getAccessToken() }) - + if (userInfo) { // 更新用户信息 userId.value = String(userInfo.id || userInfo.userId || userId.value) @@ -142,11 +144,11 @@ export const useUserStore = defineStore('user', () => { async function logout() { // 1. 清空所有 token try { - clearAllTokens() + tokenManager.clearTokens() } catch (e) { console.error('清空 token 失败:', e) } - + // 2. 清空用户信息 isLoggedIn.value = false userId.value = '' @@ -159,7 +161,7 @@ export const useUserStore = defineStore('user', () => { balance.value = 0 vipLevel.value = 0 credits.value = 0 - + // 3. 删除本地存储的用户数据 await remove(STORAGE_KEY) } diff --git a/frontend/app/web-gold/src/utils/auth.js b/frontend/app/web-gold/src/utils/auth.js deleted file mode 100644 index d61c9c0166..0000000000 --- a/frontend/app/web-gold/src/utils/auth.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Token 管理兼容层 - * - * @deprecated 此文件已废弃,请直接使用 @gold/utils/token-manager - * - * 为了保持向后兼容,此文件作为兼容层,所有函数都转发到 token-manager.js - * 新代码请直接使用 @gold/utils/token-manager 中的函数 - */ - -import { - getToken as getTokenFromManager, - setToken as setTokenFromManager, - getRefreshToken as getRefreshTokenFromManager, - getAccessToken as getAccessTokenFromManager, - removeToken as removeTokenFromManager, - clearAllTokens, - getAuthHeader -} from '@gold/utils/token-manager' - -/** - * 获取访问令牌 - * @deprecated 使用 @gold/utils/token-manager 中的 getToken() 或 getAccessToken() - */ -export const getAccessToken = getAccessTokenFromManager - -/** - * 获取刷新令牌 - * @deprecated 使用 @gold/utils/token-manager 中的 getRefreshToken() - */ -export const getRefreshToken = getRefreshTokenFromManager - -/** - * 设置令牌 - * @deprecated 使用 @gold/utils/token-manager 中的 setToken() - */ -export const setToken = setTokenFromManager - -/** - * 删除令牌 - * @deprecated 使用 @gold/utils/token-manager 中的 clearAllTokens() 或 removeToken() - */ -export const removeToken = removeTokenFromManager - -/** - * 格式化token(jwt格式) - * @param {string} token - token 字符串 - * @returns {string} Bearer token - */ -export const formatToken = (token) => { - return 'Bearer ' + token -} - -// 导出 token-manager 的其他函数,方便迁移 -export { clearAllTokens, getAuthHeader } diff --git a/frontend/app/web-gold/src/utils/streamChat.js b/frontend/app/web-gold/src/utils/streamChat.js index ab72a30177..48c38041ef 100644 --- a/frontend/app/web-gold/src/utils/streamChat.js +++ b/frontend/app/web-gold/src/utils/streamChat.js @@ -252,13 +252,13 @@ export async function streamChat(options = {}) { if (typewriter) typewriter.flush() const errorMsg = err?.message || '网络请求失败' console.error('SSE请求错误:', err) - + if (typeof onError === 'function') { onError(err) } else { message.error(errorMsg) } - + reject(new Error(errorMsg)) } }, diff --git a/frontend/app/web-gold/src/views/auth/Login.vue b/frontend/app/web-gold/src/views/auth/Login.vue new file mode 100644 index 0000000000..1a849c7c2f --- /dev/null +++ b/frontend/app/web-gold/src/views/auth/Login.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/frontend/app/web-gold/src/views/content-style/Copywriting.vue b/frontend/app/web-gold/src/views/content-style/Copywriting.vue index e2e4a58161..3789ec3928 100644 --- a/frontend/app/web-gold/src/views/content-style/Copywriting.vue +++ b/frontend/app/web-gold/src/views/content-style/Copywriting.vue @@ -10,6 +10,7 @@ import { UserPromptApi } from '@/api/userPrompt' import { useUserStore } from '@/stores/user' import GradientButton from '@/components/GradientButton.vue' import { setJSON, getJSON } from '@/utils/storage' +import authService from '@/services/AuthService' const promptStore = usePromptStore() const userStore = useUserStore() @@ -379,6 +380,20 @@ async function generateCopywriting() { if (!isResolved) { errorOccurred = true ctrl.abort() + + // 尝试解析错误中的状态码和业务码 + const status = err?.response?.status + const data = err?.response?.data + + // 处理 401/403 认证错误 + if (status === 401 || status === 403 || + (data && typeof data.code === 'number' && (data.code === 401 || data.code === 403))) { + authService.handleAuthError(err, () => { + window.location.href = '/login' + }) + return + } + const errorMsg = err?.message || '网络请求失败' console.error('SSE请求错误:', err) message.error(errorMsg) diff --git a/frontend/app/web-gold/src/views/trends/Forecast.vue b/frontend/app/web-gold/src/views/trends/Forecast.vue index 928d0ddb9f..2d4e9475e4 100644 --- a/frontend/app/web-gold/src/views/trends/Forecast.vue +++ b/frontend/app/web-gold/src/views/trends/Forecast.vue @@ -7,6 +7,7 @@ import { UserPromptApi } from '@/api/userPrompt' import { useUserStore } from '@/stores/user' import GradientButton from '@/components/GradientButton.vue' import { getVoiceText } from '@gold/hooks/web/useVoiceText' +import authService from '@/services/AuthService' defineOptions({ name: 'ForecastView' }) @@ -272,6 +273,20 @@ async function handleGenerate() { errorOccurred = true isResolved = true ctrl.abort() + + // 尝试解析错误中的状态码和业务码 + const status = err?.response?.status + const data = err?.response?.data + + // 处理 401/403 认证错误 + if (status === 401 || status === 403 || + (data && typeof data.code === 'number' && (data.code === 401 || data.code === 403))) { + authService.handleAuthError(err, () => { + window.location.href = '/login' + }) + return + } + const errorMsg = err?.message || '网络请求失败' console.error('SSE请求错误:', err) message.error(errorMsg) diff --git a/frontend/config/api.js b/frontend/config/api.js index f9bb375e4d..5d30085b13 100644 --- a/frontend/config/api.js +++ b/frontend/config/api.js @@ -31,7 +31,7 @@ export const API_BASE = { // 具体模块路径 APP_AI: `${BASE_URL}/api/ai`, APP_MEMBER: `${BASE_URL}/app-api/member`, - + // 特殊路径 TIKHUB_APP: `${BASE_URL}/api/tikHup`, AI_APP: `${BASE_URL}/api/ai`, diff --git a/frontend/hooks/web/README.md b/frontend/hooks/web/README.md deleted file mode 100644 index 26cf2cec4a..0000000000 --- a/frontend/hooks/web/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# 公共 Hooks 模块 - -此目录包含可在 monorepo 各个应用中复用的 Vue Composition API Hooks。 - -## 📁 目录结构 - -``` -hooks/web/ -├── useCache.js # 缓存管理 Hook -├── useUserInfo.js # 用户信息获取 Hook -└── useVoiceText.ts # 语音文本处理 Hook(已抽离为公共模块) -``` - -## 🚀 使用方式 - -### useUserInfo Hook - -获取用户信息的公共 Hook,可在各个应用中复用。 - -#### 基础用法 - -```javascript -import { useUserInfo } from '@gold/hooks/web/useUserInfo' -import { getToken } from '@/utils/token-manager' - -// 在组件中使用 -const { fetchUserInfo, loading, error, userInfo } = useUserInfo({ - getToken, // 传入获取 token 的函数 -}) - -// 获取用户信息 -await fetchUserInfo() -``` - -#### 便捷函数 - -如果只需要获取一次用户信息,可以使用便捷函数: - -```javascript -import { getUserInfo } from '@gold/hooks/web/useUserInfo' -import { getToken } from '@/utils/token-manager' - -// 直接获取用户信息 -const userInfo = await getUserInfo({ - getToken, -}) -``` - -#### 自定义配置 - -```javascript -const { fetchUserInfo } = useUserInfo({ - baseUrl: '/custom-api/member', // 自定义 API 基础 URL - getToken: () => { - // 自定义获取 token 的逻辑 - return localStorage.getItem('token') - }, -}) -``` - -## 📝 API 说明 - -### useUserInfo(options) - -**参数:** -- `options.baseUrl` (string, 可选): API 基础 URL,默认使用 `API_BASE.APP_MEMBER` -- `options.getToken` (Function, 可选): 获取 token 的函数 - -**返回值:** -- `fetchUserInfo()` (Function): 获取用户信息的异步函数 -- `loading` (Ref): 加载状态 -- `error` (Ref): 错误信息 -- `userInfo` (Ref): 用户信息数据 - -### getUserInfo(options) - -便捷函数,直接返回用户信息对象。 - -**参数:** 同 `useUserInfo` - -**返回值:** `Promise` 用户信息对象 - -## 🔄 在 Store 中使用 - -```javascript -import { defineStore } from 'pinia' -import { getUserInfo } from '@gold/hooks/web/useUserInfo' -import { getToken } from '@/utils/token-manager' - -export const useUserStore = defineStore('user', () => { - const userInfo = ref(null) - - async function fetchUserInfo() { - try { - const data = await getUserInfo({ getToken }) - userInfo.value = data - } catch (error) { - console.error('获取用户信息失败:', error) - } - } - - return { userInfo, fetchUserInfo } -}) -``` - -### useVoiceText Hook - -语音文本转换 Hook,将音频文件转换为文本转录。 - -#### 使用方式 - -```javascript -import useVoiceText from '@gold/hooks/web/useVoiceText' -import type { AudioItem } from '@gold/config/types' - -// 在组件中使用(无需任何初始化) -const { getVoiceText } = useVoiceText() - -const audioList: AudioItem[] = [ - { audio_url: 'https://example.com/audio.mp3' } -] - -const transcriptions = await getVoiceText(audioList) -// transcriptions: [{ key: 'url', value: 'transcribed text', audio_url: '...' }] -``` - -#### 说明 - -`useVoiceText` Hook 直接使用 mono 级别的 `TikHubService`,无需任何初始化或配置。 -所有 API 服务都在 `@gold/api/services` 中统一管理,开箱即用。 - -#### 类型定义 - -```typescript -import type { - AudioItem, - TranscriptionResult -} from '@gold/config/types' -``` - -## 📦 依赖 - -- `vue`: Vue 3 Composition API -- `axios`: HTTP 请求库(用于 useUserInfo) -- `@gold/config/api`: 公共 API 配置 -- `@gold/api/services`: Mono 级别的 API 服务 -- `@gold/api/axios/client`: Mono 级别的 Axios 客户端 -- `@gold/config/types`: 公共类型定义 - -## 🔧 配置要求 - -确保在应用的 `vite.config.js` 中配置了 `@gold` 别名: - -```javascript -resolve: { - alias: { - '@gold': fileURLToPath(new URL('../../', import.meta.url)) - } -} -``` - diff --git a/frontend/hooks/web/useUserInfo.js b/frontend/hooks/web/useUserInfo.js index c818a51fc5..bc0fea23fe 100644 --- a/frontend/hooks/web/useUserInfo.js +++ b/frontend/hooks/web/useUserInfo.js @@ -20,7 +20,7 @@ let getTokenFn = null * 设置获取 token 的函数 * @param {Function} fn - 获取 token 的函数 */ -export function setTokenGetter(fn) { +export function setTokensGetter(fn) { getTokenFn = fn } @@ -60,7 +60,7 @@ export function useUserInfo(options = {}) { // 如果传入了 getToken 函数,设置它 if (options.getToken) { - setTokenGetter(options.getToken) + setTokensGetter(options.getToken) } // 确定 API 基础路径 @@ -140,6 +140,10 @@ export function useUserInfo(options = {}) { * @returns {Promise} 用户信息对象 */ export async function getUserInfo(options = {}) { + // 如果传入了 getToken 函数,设置到全局 + if (options.getToken) { + setTokensGetter(options.getToken) + } const { fetchUserInfo } = useUserInfo(options) return await fetchUserInfo() } diff --git a/frontend/utils/README.md b/frontend/utils/README.md deleted file mode 100644 index 4501b2aec6..0000000000 --- a/frontend/utils/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# 全局工具模块 (Monorepo Utils) - -此目录包含可在 monorepo 各个应用中复用的全局工具函数。 - -## 📁 目录结构 - -``` -utils/ -├── token-manager.js # Token 统一管理工具 -└── README.md # 本文件 -``` - -## 🚀 使用方式 - -### Token 管理 (`token-manager.js`) - -在应用中使用全局 token 管理工具: - -```javascript -// 导入 token 管理工具 -import { - getToken, - setToken, - clearAllTokens, - getAuthHeader -} from '@gold/utils/token-manager' - -// 获取 token -const token = getToken() - -// 设置 token -setToken({ - accessToken: 'xxx', - refreshToken: 'yyy' -}) - -// 获取 Authorization Header -const header = getAuthHeader() // Bearer xxx - -// 清空所有 token -clearAllTokens() -``` - -## 📝 API 说明 - -### Token 管理函数 - -#### `getToken()` -获取访问令牌(按优先级读取) - -**返回值:** `string` - token 字符串或空字符串 - -#### `getAccessToken()` -获取访问令牌(别名,兼容旧代码) - -**返回值:** `string` - token 字符串或空字符串 - -#### `getRefreshToken()` -获取刷新令牌 - -**返回值:** `string | null` - 刷新令牌或 null - -#### `setToken(tokens)` -设置访问令牌和刷新令牌 - -**参数:** -- `tokens.accessToken` (string, 可选): 访问令牌 -- `tokens.refreshToken` (string, 可选): 刷新令牌 - -#### `clearAllTokens()` -清除所有 token(包括 dev token、access token、refresh token) - -#### `removeToken()` -删除 token(别名,调用 `clearAllTokens()`) - -#### `getAuthHeader()` -获取完整的 Authorization Header 值 - -**返回值:** `string` - `Bearer token` 或空字符串 - -#### `setDevToken(token)` -设置手动输入的 dev token(用于开发测试) - -**参数:** `token` (string) - dev token - -#### `getDevToken()` -获取手动输入的 dev token - -**返回值:** `string` - dev token 或空字符串 - -## 🔄 Token 存储优先级 - -1. **手动输入的 dev token** (sessionStorage) - 最高优先级 -2. **正式登录的 token** (wsCache/localStorage) -3. **环境变量 VITE_DEV_TOKEN** - 兜底 - -## 📦 在 Vite 配置中使用 - -确保在 `vite.config.js` 中配置了 `@gold` 别名: - -```javascript -resolve: { - alias: { - '@gold': fileURLToPath(new URL('../../', import.meta.url)) - } -} -``` - diff --git a/frontend/utils/token-manager.js b/frontend/utils/token-manager.js index 6c2d2e7f25..459c991534 100644 --- a/frontend/utils/token-manager.js +++ b/frontend/utils/token-manager.js @@ -1,215 +1,232 @@ -import { useCache, CACHE_KEY, deleteTokenCache } from '../hooks/web/useCache' - /** - * Token 统一管理模块(Monorepo 全局工具) - * - * 这是项目中唯一的 token 管理入口,所有 token 操作都应该通过此模块进行。 - * - * Token 存储优先级(读取顺序): - * 1. 手动输入的 dev token (sessionStorage) - * 2. 正式登录的 token (wsCache/localStorage) - * 3. 环境变量 VITE_DEV_TOKEN - * - * Token 存储位置: - * - ACCESS_TOKEN/access_token: 访问令牌(wsCache/localStorage) - * - REFRESH_TOKEN/refresh_token: 刷新令牌(wsCache/localStorage) - * - DEV_MANUAL_TOKEN: 开发手动输入的token(sessionStorage) + * Token 统一管理器 + * + * 功能特性: + * - ✅ 安全存储访问令牌、刷新令牌、过期时间等信息 + * - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间) + * * ✅ 提供订阅者模式,监听令牌变化事件 + * ✅ 兼容旧版本 API,提供 getToken()、removeToken() 等方法 + * + * 使用示例: + * ```javascript + * import { getAccessToken, setTokens, isLoggedIn } from '@gold/utils/token-manager' + * + * // 检查登录状态 + * if (isLoggedIn()) { + * const token = getAccessToken() + * } + * + * // 设置令牌 + * setTokens({ + * accessToken: 'eyJ...', + * refreshToken: 'eyJ...', + * expiresIn: 7200, + * tokenType: 'Bearer' + * }) + * ``` */ -// ==================== 常量定义 ==================== -const DEV_MANUAL_TOKEN_KEY = 'DEV_MANUAL_TOKEN' - -// Token 键名变体(支持大小写不同) +// localStorage 中存储的键名常量 const TOKEN_KEYS = { - ACCESS: [CACHE_KEY.ACCESS_TOKEN, 'access_token'], - REFRESH: [CACHE_KEY.REFRESH_TOKEN, 'refresh_token'] + ACCESS_TOKEN: 'access_token', + REFRESH_TOKEN: 'refresh_token', + EXPIRES_TIME: 'expires_time', + TOKEN_TYPE: 'token_type', } -// ==================== 缓存实例管理 ==================== -let wsCache = null +class TokenManager { + constructor() { + this.subscribers = [] // 订阅token变化的回调 + } -/** - * 获取 wsCache 实例(延迟初始化,避免模块加载时出错) - * @returns {Object} wsCache 实例 - */ -function getCache() { - if (!wsCache) { - try { - wsCache = useCache().wsCache - } catch (e) { - console.warn('初始化 wsCache 失败:', e) - // 返回一个安全的空对象,避免后续调用出错 - wsCache = { - get: () => null, - set: () => {}, - delete: () => {} - } + /** + * 获取访问令牌 + * @returns {string|null} 访问令牌,如果不存在则返回 null + */ + getAccessToken() { + return localStorage.getItem(TOKEN_KEYS.ACCESS_TOKEN) + } + + /** + * 获取刷新令牌 + * @returns {string|null} 刷新令牌,如果不存在则返回 null + */ + getRefreshToken() { + return localStorage.getItem(TOKEN_KEYS.REFRESH_TOKEN) + } + + /** + * 获取过期时间(毫秒时间戳) + * @returns {number} 过期时间戳,如果未设置则返回 0 + */ + getExpiresTime() { + const expiresTimeStr = localStorage.getItem(TOKEN_KEYS.EXPIRES_TIME) + return expiresTimeStr ? parseInt(expiresTimeStr, 10) : 0 + } + + /** + * 获取令牌类型 + * @returns {string} 令牌类型,默认为 'Bearer' + */ + getTokenType() { + return localStorage.getItem(TOKEN_KEYS.TOKEN_TYPE) || 'Bearer' + } + + /** + * 检查访问令牌是否已过期 + * @param {number} bufferTime - 提前刷新缓冲时间(毫秒),默认 5 分钟 + * @returns {boolean} 如果已过期或即将过期(超过缓冲时间)则返回 true + */ + isExpired(bufferTime = 5 * 60 * 1000) { + const expiresTime = this.getExpiresTime() + const now = Date.now() + + // 没有过期时间或当前时间已超过(过期时间 - 缓冲时间) + return !expiresTime || now >= (expiresTime - bufferTime) + } + + /** + * 检查用户是否已登录(有有效的访问令牌) + * @returns {boolean} 如果有有效令牌则返回 true + */ + isLoggedIn() { + const token = this.getAccessToken() + return Boolean(token) && !this.isExpired() + } + + /** + * 存储访问令牌和相关信息到本地存储 + * @param {Object} tokenInfo - 令牌信息对象 + * @param {string} tokenInfo.accessToken - 访问令牌(必填) + * @param {string} tokenInfo.refreshToken - 刷新令牌(可选) + * @param {number} tokenInfo.expiresIn - 令牌有效期(秒,可选) + * @param {string} tokenInfo.tokenType - 令牌类型,默认为 'Bearer' + */ + setTokens(tokenInfo) { + const { + accessToken, + refreshToken, + expiresIn, + tokenType = 'Bearer' + } = tokenInfo + + // 校验:必须提供访问令牌 + if (!accessToken) { + console.error('[TokenManager] 设置令牌失败:缺少 accessToken') + return + } + + // 将过期时间从秒转换为毫秒时间戳 + const expiresTime = expiresIn ? Date.now() + (expiresIn * 1000) : 0 + + // 存储到 localStorage + localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken) + + if (refreshToken) { + localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN, refreshToken) + } + + if (expiresTime) { + localStorage.setItem(TOKEN_KEYS.EXPIRES_TIME, String(expiresTime)) + } + + localStorage.setItem(TOKEN_KEYS.TOKEN_TYPE, tokenType) + + // 通知所有订阅者令牌已更新 + this.notifySubscribers('token-set', tokenInfo) + } + + /** + * 清除所有存储的令牌信息 + * 从 localStorage 中删除所有令牌相关数据,并通知订阅者 + */ + clearTokens() { + // 删除所有令牌相关的 localStorage 项 + Object.values(TOKEN_KEYS).forEach(key => { + localStorage.removeItem(key) + }) + + // 通知所有订阅者令牌已被清除 + this.notifySubscribers('token-cleared', null) + } + + /** + * 生成完整的 Authorization 请求头值 + * @returns {string} 例如:"Bearer eyJhbGciOi...",如果没有令牌则返回空字符串 + */ + getAuthHeader() { + const token = this.getAccessToken() + const tokenType = this.getTokenType() + + // 只有当令牌存在时才返回完整的 Authorization 头 + return token ? `${tokenType} ${token}` : '' + } + + /** + * 订阅令牌变化事件 + * @param {Function} callback - 回调函数,接收 (type, tokenInfo) 两个参数 + * - type: 事件类型('token-set' | 'token-cleared') + * - tokenInfo: 令牌信息对象或 null + */ + subscribe(callback) { + // 仅接受函数类型的回调 + if (typeof callback === 'function') { + this.subscribers.push(callback) } } - return wsCache -} -/** - * 获取完整的 Authorization Header 值 - * @returns {string} Bearer token 或空字符串 - */ -export function getAuthHeader() { - const token = getToken() - return token ? `Bearer ${token}` : '' -} + /** + * 取消令牌变化事件订阅 + * @param {Function} callback - 要取消的回调函数 + */ + unsubscribe(callback) { + const index = this.subscribers.indexOf(callback) -/** - * 获取 token - * @returns {string} token 字符串 - */ -export function getToken() { - // 1. 优先使用手动输入的 dev token - const manualToken = sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) - if (manualToken) { - return manualToken - } - - // 2. 使用正式登录的 token(从 wsCache 读取) - try { - const cache = getCache() - // 尝试所有可能的键名变体 - for (const key of TOKEN_KEYS.ACCESS) { - const accessToken = cache.get(key) - if (accessToken) { - return accessToken - } + // 如果找到回调函数,则从数组中移除 + if (index !== -1) { + this.subscribers.splice(index, 1) } - } catch (e) { - console.warn('获取 wsCache 失败:', e) } - // 3. 兜底:环境变量中的 token - const envToken = import.meta?.env?.VITE_DEV_TOKEN - if (envToken) { - return envToken - } - - return '' -} - -/** - * 设置手动输入的 dev token - * @param {string} token - */ -export function setDevToken(token) { - if (token) { - sessionStorage.setItem(DEV_MANUAL_TOKEN_KEY, token) - } else { - sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY) - } -} - -/** - * 获取手动输入的 dev token(用于显示) - * @returns {string} - */ -export function getDevToken() { - return sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) || '' -} - -/** - * 设置访问令牌和刷新令牌 - * @param {Object} tokens - token 对象 - * @param {string} tokens.accessToken - 访问令牌 - * @param {string} tokens.refreshToken - 刷新令牌 - */ -export function setToken(tokens) { - if (!tokens || (!tokens.accessToken && !tokens.refreshToken)) { - console.warn('setToken: token 参数无效', tokens) - return - } - - try { - const cache = getCache() - if (tokens.accessToken) { - cache.set(CACHE_KEY.ACCESS_TOKEN, tokens.accessToken) - } - if (tokens.refreshToken) { - cache.set(CACHE_KEY.REFRESH_TOKEN, tokens.refreshToken) - } - } catch (e) { - console.error('设置 token 失败:', e) - } -} - -/** - * 获取刷新令牌 - * @returns {string|null} 刷新令牌或 null - */ -export function getRefreshToken() { - try { - const cache = getCache() - // 尝试所有可能的键名变体 - for (const key of TOKEN_KEYS.REFRESH) { - const token = cache.get(key) - if (token) { - return token - } - } - } catch (e) { - console.warn('获取 refresh token 失败:', e) - } - return null -} - -/** - * 清除所有 token - * 清空所有可能的 token 存储位置,确保完全清除 - */ -export function clearAllTokens() { - try { - // 1. 清空 sessionStorage 中的 dev token - sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY) - } catch (e) { - console.warn('清除 sessionStorage token 失败:', e) - } - - try { - // 2. 清空 wsCache (localStorage) 中的所有可能的 token 键名 - // 使用统一的 deleteTokenCache 函数,保持与 useCache 的一致性 - deleteTokenCache() - } catch (e) { - console.warn('清除 wsCache token 失败:', e) - } - - try { - // 3. 清空 localStorage 中可能的 token(兜底处理) - // 注意:wsCache 默认使用 localStorage,但为了确保清除,也直接操作 localStorage - const localStorageKeys = [ - ...TOKEN_KEYS.ACCESS, - ...TOKEN_KEYS.REFRESH, - DEV_MANUAL_TOKEN_KEY - ] - localStorageKeys.forEach(key => { + /** + * 通知所有订阅者令牌已发生变化 + * @param {string} type - 事件类型 + * @param {Object|null} tokenInfo - 令牌信息 + */ + notifySubscribers(type, tokenInfo) { + // 遍历所有订阅者并调用其回调函数 + this.subscribers.forEach(callback => { try { - localStorage.removeItem(key) - } catch (e) { - // 忽略单个键删除失败 + callback(type, tokenInfo) + } catch (error) { + // 订阅者回调出错不应影响其他回调的执行 + console.error('[TokenManager] 订阅者回调执行失败:', error) } }) - } catch (e) { - console.warn('清除 localStorage token 失败:', e) + } + + /** + * 【兼容方法】获取访问令牌 + * @deprecated 请使用 getAccessToken() 替代 + * @returns {string|null} 访问令牌 + */ + getToken() { + return this.getAccessToken() + } + + /** + * 【兼容方法】移除所有令牌 + * @deprecated 请使用 clearTokens() 替代 + */ + removeToken() { + this.clearTokens() } } -/** - * 删除 token(别名,兼容旧代码) - * @deprecated 使用 clearAllTokens() 代替 - */ -export function removeToken() { - clearAllTokens() -} - -/** - * 获取访问令牌(别名,兼容旧代码) - * @returns {string} token 字符串 - */ -export function getAccessToken() { - return getToken() -} +// 创建 TokenManager 单例实例 +const tokenManager = new TokenManager() +// 最简单的导出方式:直接导出实例 +// 使用方法:import tokenManager from '@gold/utils/token-manager' +// tokenManager.getAccessToken() +export default tokenManager diff --git a/混剪系统实现文档.md b/混剪系统实现文档.md deleted file mode 100644 index ebed5ab46c..0000000000 --- a/混剪系统实现文档.md +++ /dev/null @@ -1,351 +0,0 @@ -# 混剪系统完整实现文档 - -## 📋 项目概述 - -本混剪系统基于 Yudao(芋道)平台开发,集成了阿里云 ICE 视频编辑服务,提供完整的任务管理、状态跟踪、进度监控和结果下载功能。 - -## 🏗️ 系统架构 - -``` -前端 (Vue.js) - ↓ HTTP请求 -后端 Controller - ↓ 调用 -Service 层 (业务逻辑) - ↓ 使用 -Mapper 层 (数据访问) - ↓ 操作 -MySQL 数据库 - ↓ 定时任务 -第三方服务 (阿里云 ICE) -``` - -## 📁 项目结构 - -### 后端模块 (yudao-module-tik) - -``` -mix/ -├── constants/ -│ └── MixTaskConstants.java # 常量定义 -├── controller/ -│ └── MixTaskController.java # REST API 控制器 -├── service/ -│ ├── MixTaskService.java # 业务接口 -│ └── MixTaskServiceImpl.java # 业务实现 -├── util/ -│ └── MixTaskUtils.java # 工具类 -├── dal/ -│ ├── dataobject/ -│ │ └── MixTaskDO.java # 数据对象 -│ └── mysql/ -│ └── MixTaskMapper.java # 数据访问层 -├── vo/ -│ ├── MixTaskSaveReqVO.java # 创建请求VO -│ ├── MixTaskRespVO.java # 响应VO -│ ├── MixTaskPageReqVO.java # 分页请求VO -│ └── MixTaskUpdateReqVO.java # 更新请求VO -├── job/ -│ └── MixTaskStatusSyncJob.java # 定时任务 -└── config/ - └── MixTaskConfig.java # 定时任务配置 -``` - -### 前端模块 (frontend/app/web-gold) - -``` -src/ -├── api/ -│ └── mixTask.js # 混剪任务 API -└── views/material/ - ├── MaterialList.vue # 素材列表(含混剪) - └── MixTaskList.vue # 混剪任务列表 -``` - -### 数据库 - -``` -sql/mysql/ -└── V20241220__create_tik_mix_task.sql # 混剪任务表 -``` - -## 🔌 API 接口 - -### 1. 创建混剪任务 -- **URL**: `POST /api/mix/create` -- **描述**: 创建新的混剪任务 -- **请求体**: - ```json - { - "title": "视频标题", - "text": "文案内容", - "videoUrls": ["视频URL1", "视频URL2"], - "bgMusicUrls": ["音频URL1"], - "produceCount": 3 - } - ``` -- **响应**: - ```json - { - "code": 0, - "data": 12345, - "msg": "成功" - } - ``` - -### 2. 获取任务分页 -- **URL**: `GET /api/mix/page` -- **查询参数**: - - `status`: 任务状态(pending/running/success/failed) - - `title`: 标题搜索 - - `pageNo`: 页码 - - `pageSize`: 页大小 - -### 3. 查询任务状态 -- **URL**: `GET /api/mix/status/{id}` -- **响应**: - ```json - { - "code": 0, - "data": { - "id": 12345, - "title": "视频标题", - "status": "running", - "progress": 65, - "outputUrls": ["输出URL1"], - "createTime": "2024-12-20T10:00:00" - } - } - ``` - -### 4. 重新生成失败任务 -- **URL**: `POST /api/mix/retry/{id}` - -### 5. 删除任务 -- **URL**: `DELETE /api/mix/delete/{id}` - -## 🔄 任务流程 - -### 任务状态流转 -``` -创建 (pending) - ↓ 提交到 ICE -处理中 (running, 进度10%) - ↓ ICE 返回 jobId -处理中 (running, 进度50%) - ↓ 定时任务检查状态 -处理中 (running, 进度N%) - ↓ ICE 完成 -已完成 (success, 进度100%) - ↓ 或 -失败 (failed, 进度100%) -``` - -### 详细流程 - -1. **用户提交任务** - - 前端调用 `/api/mix/create` - - Controller 获取当前登录用户ID(从 SecurityContext) - - 创建任务记录(pending状态) - -2. **异步提交到 ICE** - - 异步线程调用阿里云 ICE API - - 返回 jobId 和输出URL - - 更新任务状态(running,进度10%→50%) - -3. **定时检查状态** - - 每30秒执行一次 - - 查询所有 running 状态的任务 - - 调用 ICE API 检查每个 jobId 的状态 - - 更新任务进度和状态 - -4. **任务完成** - - 状态变为 success/failed - - 记录完成时间 - - 用户可在任务列表查看结果 - -## 🔐 用户认证 - -### 获取当前用户 -使用 `SecurityFrameworkUtils.getLoginUserId()` 从 SecurityContext 获取当前登录用户ID: - -```java -Long userId = SecurityFrameworkUtils.getLoginUserId(); -if (userId == null) { - return CommonResult.error("用户未登录"); -} -``` - -**优势**: -- ✅ 自动从请求头获取认证信息 -- ✅ 支持 Token 认证 -- ✅ 多租户隔离 -- ✅ 权限控制 - -## 🗄️ 数据库设计 - -### 混剪任务表 (tik_mix_task) - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | bigint | 主键 | -| tenant_id | bigint | 租户ID | -| user_id | bigint | 用户ID | -| title | varchar(255) | 视频标题 | -| text | text | 文案内容 | -| video_urls | text | 视频URL列表(JSON) | -| bg_music_urls | text | 背景音乐URL列表(JSON) | -| produce_count | int | 生成数量 | -| job_ids | text | ICE任务ID列表(JSON) | -| output_urls | text | 输出文件URL列表(JSON) | -| status | varchar(32) | 任务状态 | -| progress | int | 进度(0-100) | -| error_msg | text | 错误信息 | -| create_time | datetime | 创建时间 | -| update_time | datetime | 更新时间 | -| finish_time | datetime | 完成时间 | - -### 状态常量 -```java -public static final String STATUS_PENDING = "pending"; // 待处理 -public static final String STATUS_RUNNING = "running"; // 处理中 -public static final String STATUS_SUCCESS = "success"; // 成功 -public static final String STATUS_FAILED = "failed"; // 失败 -``` - -## 🎯 核心功能 - -### 1. 任务管理 -- ✅ 创建任务 -- ✅ 更新任务 -- ✅ 删除任务 -- ✅ 重试任务 -- ❌ 取消任务(待开发) - -### 2. 状态监控 -- ✅ 实时进度显示 -- ✅ 状态自动同步 -- ✅ 任务历史记录 -- ✅ 错误日志记录 - -### 3. 阿里云 ICE 集成 -- ✅ 提交混剪任务 -- ✅ 检查任务状态 -- ✅ 获取输出URL -- ✅ 错误处理 - -### 4. 前端功能 -- ✅ 素材选择 -- ✅ 任务提交 -- ✅ 进度展示 -- ✅ 结果下载 -- ✅ 任务列表 - -## 📊 性能与扩展 - -### 性能指标 -- **任务创建**: < 100ms -- **状态检查**: 每30秒批量查询 -- **并发支持**: 基于线程池异步处理 -- **数据库**: 索引优化,支持分页查询 - -### 扩展性 -- ✅ 支持多租户 -- ✅ 支持分布式部署 -- ✅ 支持多个视频处理服务(可扩展) -- ✅ 支持任务优先级(可扩展) - -## 🚀 部署指南 - -### 1. 数据库迁移 -```sql --- 执行 SQL 脚本 -source sql/mysql/V20241220__create_tik_mix_task.sql; -``` - -### 2. 启动后端服务 -```bash -cd yudao-server -mvn spring-boot:run -``` - -### 3. 启动前端服务 -```bash -cd frontend/app/web-gold -pnpm install -pnpm run dev -``` - -### 4. 访问页面 -- 素材库: http://localhost:5173/material/list -- 混剪任务: http://localhost:5173/material/mix-task -- API文档: http://localhost:9900/swagger-ui.html - -## 🛠️ 开发规范 - -### 1. 代码风格 -- ✅ 使用常量类避免魔法数字 -- ✅ 方法拆分,单一职责 -- ✅ 结构化日志记录 -- ✅ 防御式编程 -- ✅ 异常处理 - -### 2. 最佳实践 -- ✅ 从 SecurityContext 获取用户ID -- ✅ 异步处理长耗时任务 -- ✅ 定时任务自动检查状态 -- ✅ 事务管理 -- ✅ 错误重试机制 - -## 📈 监控与运维 - -### 日志级别 -- **INFO**: 任务创建、状态变更 -- **WARN**: 功能未实现、用户未登录 -- **ERROR**: 任务失败、检查状态失败 - -### 定时任务 -- **Cron**: `*/30 * * * * ?` (每30秒) -- **功能**: 批量检查任务状态 -- **优化**: 无运行中任务时直接返回 - -## 🔮 后续规划 - -### 待开发功能 -1. 取消任务(调用 ICE 取消接口) -2. 任务优先级 -3. 任务队列 -4. 邮件/短信通知 -5. 批量操作 -6. 导出任务报表 - -### 性能优化 -1. 引入消息队列(RabbitMQ/Kafka) -2. 缓存热门任务数据 -3. 分布式定时任务 -4. 任务结果自动入库 - -## 📝 维护说明 - -### 常见问题 -1. **用户未登录**: 检查 Token 是否有效 -2. **任务提交失败**: 检查 ICE API 配置 -3. **状态不更新**: 检查定时任务是否正常执行 -4. **下载失败**: 检查 OSS 权限 - -### 调优建议 -1. 调整定时任务频率(根据任务量) -2. 增加 ICE API 超时时间 -3. 使用连接池优化数据库访问 -4. 添加缓存层 - -## 🎉 总结 - -本混剪系统是一个**完整的、生产级别的解决方案**,具备: -- ✅ 任务全生命周期管理 -- ✅ 实时状态跟踪 -- ✅ 用户权限控制 -- ✅ 错误处理和重试 -- ✅ 可扩展的架构设计 - -通过异步处理、定时任务和数据库持久化,确保了系统的**可靠性**和**可维护性**,满足生产环境的需求。