feat: 新增用户状态检查和 api key 准备功能
This commit is contained in:
@@ -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 报错后再结束。
|
||||
|
||||
## 非目标
|
||||
|
||||
|
||||
@@ -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<ICommonResponse<IUserReadyData>>(
|
||||
`${UserService.basePath}/ready`,
|
||||
{
|
||||
method: "GET"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static UserRegister(request: IUserRegisterRequest) {
|
||||
return $fetch<ICommonResponse<IUserRegisterData>>(
|
||||
`${UserService.basePath}/register`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IUserReadyData } from "#shared/types";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const result = await ensureAiArtStudioToken(event);
|
||||
|
||||
return createSuccessResponse<IUserReadyData>(
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./createApiResponse";
|
||||
export * from "./fetch";
|
||||
export * from "./newApiAuthCookies";
|
||||
export * from "./newApiTokens";
|
||||
|
||||
@@ -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<T> {
|
||||
/** 上游业务数据 */
|
||||
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<IUserReadyData> => {
|
||||
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<boolean> => {
|
||||
let page = 1;
|
||||
let total = Number.POSITIVE_INFINITY;
|
||||
|
||||
while ((page - 1) * TOKEN_PAGE_SIZE < total) {
|
||||
const response = await newApiAuthedFetch<
|
||||
INewApiWrappedResponse<INewApiTokenPageData>
|
||||
>(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<INewApiWrappedResponse<null>>(
|
||||
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 = <T>(
|
||||
response: INewApiWrappedResponse<T>,
|
||||
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;
|
||||
};
|
||||
@@ -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 表示。
|
||||
|
||||
Reference in New Issue
Block a user