From 5cb72f85795189e32ddbe90717aadf9f7396f494 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Sat, 25 Apr 2026 16:27:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A3=80=E6=9F=A5=E5=92=8C=20api=20key=20?= =?UTF-8?q?=E5=87=86=E5=A4=87=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 34 +++++++- app/services/user_service.ts | 10 +++ app/stores/user.ts | 13 +++ server/api/auth/ready.get.ts | 36 +++++++++ server/utils/index.ts | 1 + server/utils/newApiTokens.ts | 152 +++++++++++++++++++++++++++++++++++ shared/types/user.ts | 10 +++ 7 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 server/api/auth/ready.get.ts create mode 100644 server/utils/newApiTokens.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7acdbb5..71ca98a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,16 +17,39 @@ - 当前仅保留以下入口: - POST /api/auth/register - POST /api/auth/login + - GET /api/auth/me + - GET /api/auth/logout + - GET /api/auth/ready - 不再创建 server/api/user 的本地别名路由。 - 对外路径由前端统一调用 auth 前缀,不允许同一业务暴露两套路由。 +## 登录状态与环境准备规范 + +- 登录成功后,后端从 NewAPI Set-Cookie 中提取 session,并写入本项目 httpOnly cookie。 +- 前端不得读取、保存或透传 NewAPI session、完整 API Key 等敏感信息。 +- 前端通过 /api/auth/me 恢复登录状态,通过 /api/auth/logout 清理登录状态。 +- /api/auth/ready 只负责检查当前账号运行环境是否就绪:存在 name 为 AIArtStudio、status 为 1、未删除的 Token 即视为就绪。 +- ready 检查不到可用 Token 时,由后端使用固定参数创建 AIArtStudio Token。 +- /api/auth/ready 不返回完整 key,也不调用完整 key 获取接口;返回文案使用“环境已准备 / 环境初始化完成”等业务表达,不向前端暴露 API Key 细节。 + ## 上游请求规范 -- 调用上游 NewAPI 必须使用 server/utils/fetch.ts 中的 newApiFetch。 +- 调用上游 NewAPI 必须使用 server/utils/fetch.ts 中的统一入口。 +- 普通未登录请求使用 newApiFetch。 +- 需要读取上游响应头的请求使用 newApiFetchRaw,目前主要用于登录时提取 Set-Cookie。 +- 需要 NewAPI 登录态的请求使用 newApiAuthedFetch,由服务端从 httpOnly cookie 中读取 session 和 userId 后补齐 Cookie 与 New-Api-User。 - 上游基地址只在 fetch.ts 的 BASE_URL 维护一次。 - 禁止在各个 handler 内重复写 baseURL。 - 当前策略是硬编码基地址,按项目要求保持简单直接。 +## Token 处理规范 + +- NewAPI Token 相关通用逻辑放在 server/utils/newApiTokens.ts。 +- handler 不直接拼装 Token 列表、创建参数或 ready 判定逻辑。 +- Token 列表接口返回的脱敏 key 只用于后端判断,不透出给前端。 +- 完整 key 获取接口必须单独设计服务端流程,默认不要在登录或 ready 阶段调用。 +- 上游 Token 内部结构类型优先留在 server/utils 内;只有前后端共享的返回契约才放入 shared/types。 + ## 请求体处理规范 - 所有接口先校验请求体:只接受 JSON 对象或 null。 @@ -45,8 +68,10 @@ - 用户相关请求/响应类型放在 shared/types/user.ts。 - 公共响应类型放在 shared/types/index.ts。 +- OpenAI 调用基础类型放在 shared/types/openai.ts。 - 新增接口必须先补类型,再写 handler。 - 每个类型字段都要有中文注释,说明字段含义与可选性。 +- 仅前端需要感知的接口契约放入 shared/types;服务端内部上游适配类型不要扩散到 shared。 ## 注释规范 @@ -71,9 +96,10 @@ 1. 在 shared/types 中定义请求与响应类型,并写字段注释。 2. 在 server/api/auth 新建对应 handler。 3. 在 handler 中完成请求体校验和白名单过滤。 -4. 使用 newApiFetch 调用上游接口。 -5. 用统一响应工具返回成功与错误结果。 -6. 自检 TypeScript 报错后再结束。 +4. 按请求场景选择 newApiFetch、newApiFetchRaw 或 newApiAuthedFetch 调用上游接口。 +5. 可复用的业务逻辑优先抽到 server/utils,handler 只负责入参、调用和统一响应。 +6. 用统一响应工具返回成功与错误结果。 +7. 自检 TypeScript 报错后再结束。 ## 非目标 diff --git a/app/services/user_service.ts b/app/services/user_service.ts index 882853d..10ebe11 100644 --- a/app/services/user_service.ts +++ b/app/services/user_service.ts @@ -4,6 +4,7 @@ import type { IUserLoginRequest, IUserLogoutData, IUserMeData, + IUserReadyData, IUserRegisterData, IUserRegisterRequest } from "#shared/types"; @@ -27,6 +28,15 @@ export class UserService { }); } + public static UserReady() { + return $fetch>( + `${UserService.basePath}/ready`, + { + method: "GET" + } + ); + } + public static UserRegister(request: IUserRegisterRequest) { return $fetch>( `${UserService.basePath}/register`, diff --git a/app/stores/user.ts b/app/stores/user.ts index decf391..70b57a4 100644 --- a/app/stores/user.ts +++ b/app/stores/user.ts @@ -48,6 +48,7 @@ export const useUserStore = defineStore("user", () => { isOnline.value = true; userInfo.value = res.data; toast.success(res.msg || "登录成功"); + await readyUserApiKey(); } else { isOnline.value = false; userInfo.value = null; @@ -93,6 +94,18 @@ export const useUserStore = defineStore("user", () => { } }; + /** 登录成功后准备后续 AI 调用需要的服务端 API Key,不影响登录状态本身 */ + const readyUserApiKey = async () => { + try { + const res = await UserService.UserReady(); + if (res.code !== 0) { + toast.warning(res.msg || "环境准备失败"); + } + } catch (err) { + toast.error(getErrorMessage(err, "环境准备失败")); + } + }; + /** 用户注册 */ const register = async ( request: IUserRegisterRequest diff --git a/server/api/auth/ready.get.ts b/server/api/auth/ready.get.ts new file mode 100644 index 0000000..09b53c9 --- /dev/null +++ b/server/api/auth/ready.get.ts @@ -0,0 +1,36 @@ +import type { IUserReadyData } from "#shared/types"; + +export default defineEventHandler(async (event) => { + try { + const result = await ensureAiArtStudioToken(event); + + return createSuccessResponse( + result, + result.created ? "环境初始化完成" : "环境已准备" + ); + } catch (error) { + if (isUnauthorizedError(error)) { + clearNewApiAuthCookies(event); + return createErrorResponse(401, "未登录"); + } + + return createUpstreamErrorResponse(error, "环境准备失败"); + } +}); + +/** 鉴权失败时清理本地 cookie,避免继续携带失效的 NewAPI 登录态 */ +const isUnauthorizedError = (error: unknown): boolean => { + const fetchError = error as { + response?: { + status?: number; + }; + status?: number; + statusCode?: number; + }; + + return ( + fetchError.statusCode === 401 || + fetchError.status === 401 || + fetchError.response?.status === 401 + ); +}; diff --git a/server/utils/index.ts b/server/utils/index.ts index 861e6df..f433a37 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -1,3 +1,4 @@ export * from "./createApiResponse"; export * from "./fetch"; export * from "./newApiAuthCookies"; +export * from "./newApiTokens"; diff --git a/server/utils/newApiTokens.ts b/server/utils/newApiTokens.ts new file mode 100644 index 0000000..1d04f54 --- /dev/null +++ b/server/utils/newApiTokens.ts @@ -0,0 +1,152 @@ +import type { H3Event } from "h3"; +import { createError } from "h3"; +import type { IUserReadyData } from "#shared/types"; +import { newApiAuthedFetch } from "~~/server/utils/fetch"; + +const AI_ART_STUDIO_TOKEN_NAME = "AIArtStudio"; +const TOKEN_PAGE_SIZE = 100; + +interface INewApiTokenItem { + /** NewAPI Token ID,后续获取完整 key 时会用到 */ + id: number; + /** Token 所属用户 ID */ + user_id: number; + /** 列表接口返回的脱敏 key,不对前端透出 */ + key: string; + /** Token 状态:1 为启用,2 为禁用 */ + status: number; + /** Token 名称 */ + name: string; + /** 软删除时间,null 表示未删除 */ + DeletedAt: unknown; +} + +interface INewApiTokenPageData { + /** 当前页码 */ + page: number; + /** 每页数量 */ + page_size: number; + /** Token 总数 */ + total: number; + /** 当前页 Token 列表 */ + items: INewApiTokenItem[]; +} + +interface INewApiWrappedResponse { + /** 上游业务数据 */ + data?: T | null; + /** 上游提示信息 */ + message?: string; + /** 上游业务成功状态 */ + success?: boolean; +} + +const AI_ART_STUDIO_TOKEN_PAYLOAD = { + remain_quota: 0, + remain_amount: 0, + expired_time: -1, + unlimited_quota: true, + model_limits_enabled: false, + model_limits: "", + cross_group_retry: false, + name: AI_ART_STUDIO_TOKEN_NAME, + group: "", + allow_ips: "" +}; + +/** 检查当前登录用户是否已有可用 AIArtStudio Key,没有则创建一个 */ +export const ensureAiArtStudioToken = async ( + event: H3Event +): Promise => { + const hasToken = await hasActiveAiArtStudioToken(event); + if (hasToken) { + return { + ready: true, + created: false + }; + } + + await createAiArtStudioToken(event); + + return { + ready: true, + created: true + }; +}; + +/** 分页读取 Token 列表,只认名称为 AIArtStudio 且状态启用的未删除 Token */ +const hasActiveAiArtStudioToken = async (event: H3Event): Promise => { + let page = 1; + let total = Number.POSITIVE_INFINITY; + + while ((page - 1) * TOKEN_PAGE_SIZE < total) { + const response = await newApiAuthedFetch< + INewApiWrappedResponse + >(event, `/api/token/?p=${page}&size=${TOKEN_PAGE_SIZE}`, { + method: "GET" + }); + const data = unwrapNewApiResponse(response, "环境检查失败"); + + if (data.items.some(isActiveAiArtStudioToken)) { + return true; + } + + total = data.total; + page += 1; + } + + return false; +}; + +/** 创建默认 AIArtStudio Token,参数固定,避免 handler 内重复维护 */ +const createAiArtStudioToken = async (event: H3Event) => { + const response = await newApiAuthedFetch>( + event, + "/api/token/", + { + method: "POST", + body: AI_ART_STUDIO_TOKEN_PAYLOAD, + headers: { + "Content-Type": "application/json" + } + } + ); + + unwrapNewApiResponse(response, "环境初始化失败", true); +}; + +/** 判断 Token 是否满足本项目 ready 条件 */ +const isActiveAiArtStudioToken = (token: INewApiTokenItem): boolean => { + return ( + token.name === AI_ART_STUDIO_TOKEN_NAME && + token.status === 1 && + token.DeletedAt === null + ); +}; + +/** 统一处理 NewAPI 的 { success, message, data } 外壳 */ +const unwrapNewApiResponse = ( + response: INewApiWrappedResponse, + fallbackMessage: string, + allowNullData: boolean = false +): T => { + if (response.success !== true) { + throw createError({ + statusCode: 502, + statusMessage: response.message || fallbackMessage, + data: response + }); + } + + if (response.data === null || response.data === undefined) { + if (allowNullData) return response.data as T; + + throw createError({ + statusCode: 502, + statusMessage: fallbackMessage, + data: response + }); + } + + return response.data; +}; diff --git a/shared/types/user.ts b/shared/types/user.ts index 7afed3a..81f2a6c 100644 --- a/shared/types/user.ts +++ b/shared/types/user.ts @@ -62,6 +62,16 @@ export type IUserLoginData = IUserBasicData; */ export type IUserMeData = IUserBasicData; +/** + * 登录后准备 AIArtStudio Key 的结果。 + */ +export interface IUserReadyData { + /** 是否已经确认当前账号具备可用的 AIArtStudio Key */ + ready: boolean; + /** 本次检查是否创建了新的 AIArtStudio Key */ + created: boolean; +} + /** * 登出成功时上游接口返回的数据类型。 * NewAPI 登出接口通常无业务数据返回,此处用 null 表示。