// server/api/auth/login.post.ts - 登录接口:代理 NewAPI 登录、保存 httpOnly 登录态并返回基础用户信息。 import type { IUserLoginData, IUserLoginRequest } from "#shared/types"; import { clearNewApiAuthCookies, createApiLogger, createErrorResponse, createSuccessResponse, createUpstreamErrorResponse, extractCookieMetaFromSetCookie, extractCookieValueFromSetCookie, newApiFetchRaw, setNewApiAuthCookies, toSafeLogError, upsertUserSnapshot } from "~~/server/utils"; interface INewApiWrappedLoginResponse { /** 登录成功时上游返回的用户信息 */ data?: INewApiLoginUserData | null; /** 上游提示信息 */ message?: string; /** 上游业务成功状态 */ success?: boolean; } type NewApiLoginResponse = INewApiLoginUserData | INewApiWrappedLoginResponse; interface INewApiLoginUserData extends IUserLoginData { /** NewAPI 登录结果如果返回额外字段,服务端会在返回前裁剪掉 */ [key: string]: unknown; } /** * 允许从请求体透传给上游的字段列表。 * 只有在这里声明过的字段才会被转发,其余字段一律丢弃,防止参数污染。 */ const LOGIN_FIELDS = ["username", "password"] as const satisfies ReadonlyArray< keyof IUserLoginRequest >; /** * POST /api/auth/login * * 流程: * 1. 只接收 JSON 对象,并按 LOGIN_FIELDS 白名单组装上游登录参数。 * 2. 使用 raw fetch 调用 NewAPI 登录接口,保留 Set-Cookie 响应头。 * 3. 兼容上游直接返回用户对象或包装响应,提取基础用户信息。 * 4. 将 NewAPI session 和 userId 写入本站 httpOnly cookie,前端不接触 session 明文。 * 5. 异步 upsert 用户快照到数据库,失败只记日志,不影响登录成功。 */ export default defineEventHandler(async (event) => { const logger = createApiLogger("auth.login"); const requestBody = await readBody | null>(event); logger.info("开始", { bodyType: requestBody === null ? "null" : typeof requestBody, hasUsername: typeof requestBody?.username === "string", hasPassword: typeof requestBody?.password === "string" }); // 只接受 JSON 对象或 null,避免数组/字符串等无效 body 被透传到上游。 if ( requestBody !== null && (typeof requestBody !== "object" || Array.isArray(requestBody)) ) { clearNewApiAuthCookies(event); logger.warn("请求体格式错误", { bodyType: Array.isArray(requestBody) ? "array" : typeof requestBody }); return createErrorResponse(400, "请求体必须是 JSON 对象"); } const payload: IUserLoginRequest = {}; for (const field of LOGIN_FIELDS) { const value = requestBody?.[field]; if (typeof value === "string") { payload[field] = value; } } try { // 登录必须读取上游 Set-Cookie,因此这里使用 raw fetch 保留响应头。 const result = await newApiFetchRaw( "/api/user/login", { method: "POST", body: payload, headers: { "Content-Type": "application/json" } } ); const normalized = normalizeLoginResponse(result._data); const session = extractCookieValueFromSetCookie(result.headers, "session"); logger.info("上游登录返回", { success: Boolean(normalized.user), hasSession: Boolean(session) }); if (!normalized.user) { // 上游业务失败时同步清理本地旧登录态,避免继续携带坏 cookie。 clearNewApiAuthCookies(event); logger.warn("登录业务失败", { upstreamMessage: normalized.message }); return createErrorResponse(1, "登录失败"); } if (!session) { // 没有 session 就不能恢复 NewAPI 登录态,必须让前端明确感知失败。 clearNewApiAuthCookies(event); logger.error("登录成功但未收到 NewAPI session", { userId: normalized.user.id }); return createErrorResponse(500, "服务器错误,请稍后重试"); } setNewApiAuthCookies( event, session, normalized.user.id, extractCookieMetaFromSetCookie(result.headers, "session") ); upsertUserSnapshot(normalized.user).catch((error) => { logger.error("保存用户快照失败", { userId: normalized.user?.id, error: toSafeLogError(error) }); }); logger.done("成功", { userId: normalized.user.id, role: normalized.user.role, status: normalized.user.status }); return createSuccessResponse( normalized.user, "登录成功" ); } catch (error) { clearNewApiAuthCookies(event); logger.error("失败", { error: toSafeLogError(error) }); return createUpstreamErrorResponse(error, "登录失败"); } }); /** 兼容上游直接返回用户对象和 { success, message, data } 包装结构 */ const normalizeLoginResponse = ( response: NewApiLoginResponse | undefined ): { message: string; user: IUserLoginData | null } => { if (isUser(response)) { return { message: "登录成功", user: pickUserBasicData(response) }; } if (!isRecord(response)) { return { message: "登录失败", user: null }; } const wrapped = response as INewApiWrappedLoginResponse; const message = wrapped.success ? "登录成功" : "登录失败"; if (wrapped.success !== true || !isUser(wrapped.data)) { return { message, user: null }; } return { message, user: pickUserBasicData(wrapped.data) }; }; /** 只返回前端需要的基础用户字段,避免上游额外字段透出 */ const pickUserBasicData = (user: INewApiLoginUserData): IUserLoginData => { return { id: user.id, username: user.username, display_name: user.display_name, group: user.group, role: user.role, status: user.status }; }; /** 判断值是否为合法的 NewAPI 登录用户对象,所有必需字段均须类型正确 */ const isUser = (value: unknown): value is INewApiLoginUserData => { return ( isRecord(value) && typeof value.id === "number" && typeof value.username === "string" && typeof value.display_name === "string" && typeof value.group === "string" && typeof value.role === "number" && typeof value.status === "number" ); }; /** 判断值是否为非 null 的普通对象,用于后续安全访问属性 */ const isRecord = (value: unknown): value is Record => { return value !== null && typeof value === "object"; };