@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { Toaster } from "vue-sonner";
|
||||
import { useUserStore } from "~/stores";
|
||||
import "vue-sonner/style.css";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
onMounted(() => {
|
||||
userStore.getUserInfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -19,6 +19,7 @@ const emit = defineEmits<{
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline, getUserInfoLoading } = storeToRefs(userStore);
|
||||
const formRef = ref<FormRef>();
|
||||
|
||||
const form = reactive<Required<IUserLoginRequest>>({
|
||||
@@ -99,6 +100,7 @@ defineExpose({
|
||||
size="large"
|
||||
type="primary"
|
||||
:loading="userStore.loginLoading"
|
||||
:disabled="getUserInfoLoading || isOnline"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
|
||||
@@ -26,6 +26,7 @@ const emit = defineEmits<{
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const { isOnline, getUserInfoLoading } = storeToRefs(userStore);
|
||||
const formRef = ref<FormRef>();
|
||||
|
||||
const form = reactive<RegisterForm>({
|
||||
@@ -175,6 +176,7 @@ defineExpose({
|
||||
size="large"
|
||||
type="primary"
|
||||
:loading="userStore.registerLoading"
|
||||
:disabled="getUserInfoLoading || isOnline"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ const { isOnline } = storeToRefs(userStore);
|
||||
|
||||
const isSignUp = computed(() => route.query._loginAction === "signUp");
|
||||
|
||||
onMounted(() => {
|
||||
if (isOnline.value) {
|
||||
watch(isOnline, (online) => {
|
||||
if (online) {
|
||||
navigateTo("/");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
IUserLoginData,
|
||||
IUserLoginRequest,
|
||||
IUserLogoutData,
|
||||
IUserMeData,
|
||||
IUserRegisterData,
|
||||
IUserRegisterRequest
|
||||
} from "#shared/types";
|
||||
@@ -20,6 +21,12 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
public static UserMe() {
|
||||
return $fetch<ICommonResponse<IUserMeData>>(`${UserService.basePath}/me`, {
|
||||
method: "GET"
|
||||
});
|
||||
}
|
||||
|
||||
public static UserRegister(request: IUserRegisterRequest) {
|
||||
return $fetch<ICommonResponse<IUserRegisterData>>(
|
||||
`${UserService.basePath}/register`,
|
||||
|
||||
+53
-7
@@ -1,3 +1,4 @@
|
||||
import { StorageSerializers, useLocalStorage } from "@vueuse/core";
|
||||
import { toast } from "vue-sonner";
|
||||
import type {
|
||||
ICommonResponse,
|
||||
@@ -9,30 +10,41 @@ import type {
|
||||
} from "#shared/types";
|
||||
import { UserService } from "~/services";
|
||||
|
||||
const USER_INFO_STORAGE_KEY = "newapi_user_info";
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
/** 用户登录状态 */
|
||||
const isOnline = ref(false);
|
||||
/** 用户信息 */
|
||||
const userInfo = ref<IUserLoginData | null>(null);
|
||||
const userInfo = useLocalStorage<IUserLoginData | null>(
|
||||
USER_INFO_STORAGE_KEY,
|
||||
null,
|
||||
{
|
||||
serializer: StorageSerializers.object
|
||||
}
|
||||
);
|
||||
/** 登录加载状态 */
|
||||
const loginLoading = ref(false);
|
||||
/** 注册加载状态 */
|
||||
const registerLoading = ref(false);
|
||||
/** 登出加载状态 */
|
||||
const logoutLoading = ref(false);
|
||||
/** 刷新当前用户信息加载状态 */
|
||||
const getUserInfoLoading = ref(false);
|
||||
|
||||
/** 用户登录 */
|
||||
const login = async (
|
||||
request: IUserLoginRequest
|
||||
): Promise<ICommonResponse<IUserLoginData> | null> => {
|
||||
if (loginLoading.value || isOnline.value) return null;
|
||||
if (loginLoading.value || isOnline.value || getUserInfoLoading.value)
|
||||
return null;
|
||||
|
||||
loginLoading.value = true;
|
||||
|
||||
try {
|
||||
const res = await UserService.UserLogin(request);
|
||||
|
||||
if (res.code === 0) {
|
||||
if (res.code === 0 && res.data) {
|
||||
isOnline.value = true;
|
||||
userInfo.value = res.data;
|
||||
toast.success(res.msg || "登录成功");
|
||||
@@ -53,11 +65,40 @@ export const useUserStore = defineStore("user", () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 通过 /api/auth/me 恢复刷新后的登录状态 */
|
||||
const getUserInfo =
|
||||
async (): Promise<ICommonResponse<IUserLoginData> | null> => {
|
||||
if (getUserInfoLoading.value) return null;
|
||||
|
||||
getUserInfoLoading.value = true;
|
||||
|
||||
try {
|
||||
const res = await UserService.UserMe();
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
isOnline.value = true;
|
||||
userInfo.value = res.data;
|
||||
} else {
|
||||
isOnline.value = false;
|
||||
userInfo.value = null;
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch {
|
||||
isOnline.value = false;
|
||||
userInfo.value = null;
|
||||
return null;
|
||||
} finally {
|
||||
getUserInfoLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 用户注册 */
|
||||
const register = async (
|
||||
request: IUserRegisterRequest
|
||||
): Promise<ICommonResponse<IUserRegisterData> | null> => {
|
||||
if (registerLoading.value || isOnline.value) return null;
|
||||
if (registerLoading.value || isOnline.value || getUserInfoLoading.value)
|
||||
return null;
|
||||
|
||||
registerLoading.value = true;
|
||||
|
||||
@@ -79,7 +120,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
|
||||
/** 用户登出 */
|
||||
const logout = async (): Promise<ICommonResponse<IUserLogoutData> | null> => {
|
||||
if (logoutLoading.value || !isOnline.value) return null;
|
||||
if (logoutLoading.value || getUserInfoLoading.value) return null;
|
||||
|
||||
logoutLoading.value = true;
|
||||
|
||||
@@ -87,15 +128,18 @@ export const useUserStore = defineStore("user", () => {
|
||||
const res = await UserService.UserLogout();
|
||||
|
||||
if (res.code === 0) {
|
||||
isOnline.value = false;
|
||||
userInfo.value = null;
|
||||
toast.success(res.msg || "登出成功");
|
||||
} else {
|
||||
toast.warning(res.msg || "登出失败");
|
||||
}
|
||||
|
||||
isOnline.value = false;
|
||||
userInfo.value = null;
|
||||
|
||||
return res;
|
||||
} catch (err) {
|
||||
isOnline.value = false;
|
||||
userInfo.value = null;
|
||||
toast.error(getErrorMessage(err, "登出失败"));
|
||||
return null;
|
||||
} finally {
|
||||
@@ -109,7 +153,9 @@ export const useUserStore = defineStore("user", () => {
|
||||
loginLoading,
|
||||
registerLoading,
|
||||
logoutLoading,
|
||||
getUserInfoLoading,
|
||||
login,
|
||||
getUserInfo,
|
||||
register,
|
||||
logout
|
||||
};
|
||||
|
||||
+2
-1
@@ -18,7 +18,6 @@
|
||||
"@nuxtjs/tailwindcss": "6.14.0",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"@takumi-rs/core": "^1.0.16",
|
||||
"@vueuse/nuxt": "14.2.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
@@ -26,6 +25,8 @@
|
||||
"vue-sonner": "^2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "14.2.1",
|
||||
"vue-tsc": "^3.2.7"
|
||||
}
|
||||
}
|
||||
Generated
+218
-199
File diff suppressed because it is too large
Load Diff
+115
-15
@@ -1,21 +1,31 @@
|
||||
// server/api/auth/login.post.ts
|
||||
import type { IUserLoginData, IUserLoginRequest } from "#shared/types";
|
||||
import {
|
||||
clearNewApiAuthCookies,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
createUpstreamErrorResponse,
|
||||
newApiFetch
|
||||
extractCookieMetaFromSetCookie,
|
||||
extractCookieValueFromSetCookie,
|
||||
newApiFetchRaw,
|
||||
setNewApiAuthCookies
|
||||
} from "~~/server/utils";
|
||||
|
||||
interface INewApiLoginResponse {
|
||||
interface INewApiWrappedLoginResponse {
|
||||
/** 登录成功时上游返回的用户信息 */
|
||||
data?: IUserLoginData | null;
|
||||
data?: INewApiLoginUserData | null;
|
||||
/** 上游提示信息 */
|
||||
message?: string;
|
||||
/** 上游业务成功状态 */
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
type NewApiLoginResponse = INewApiLoginUserData | INewApiWrappedLoginResponse;
|
||||
|
||||
interface INewApiLoginUserData extends IUserLoginData {
|
||||
/** NewAPI 登录结果如果返回额外字段,服务端会在返回前裁剪掉 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* 允许从请求体透传给上游的字段列表。
|
||||
* 只有在这里声明过的字段才会被转发,其余字段一律丢弃,防止参数污染。
|
||||
@@ -30,7 +40,9 @@ const LOGIN_FIELDS = ["username", "password"] as const satisfies ReadonlyArray<
|
||||
export default defineEventHandler(async (event) => {
|
||||
const requestBody = await readBody<Partial<IUserLoginRequest> | null>(event);
|
||||
|
||||
// 只接受 JSON 对象或 null,避免数组/字符串等无效 body 被透传到上游。
|
||||
if (requestBody !== null && typeof requestBody !== "object") {
|
||||
clearNewApiAuthCookies(event);
|
||||
return createErrorResponse(400, "请求体必须是 JSON 对象");
|
||||
}
|
||||
|
||||
@@ -43,23 +55,111 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await newApiFetch<INewApiLoginResponse>("/api/user/login", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
// 登录必须读取上游 Set-Cookie,因此这里使用 raw fetch 保留响应头。
|
||||
const result = await newApiFetchRaw<NewApiLoginResponse>(
|
||||
"/api/user/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const message =
|
||||
result.message || (result.success ? "登录成功" : "登录失败");
|
||||
|
||||
if (result.success !== true) {
|
||||
return createErrorResponse(1, message, result.data ?? null);
|
||||
const normalized = normalizeLoginResponse(result._data);
|
||||
if (!normalized.user) {
|
||||
// 上游业务失败时同步清理本地旧登录态,避免继续携带坏 cookie。
|
||||
clearNewApiAuthCookies(event);
|
||||
return createErrorResponse(1, normalized.message, result._data ?? null);
|
||||
}
|
||||
|
||||
return createSuccessResponse<IUserLoginData>(result.data ?? null, message);
|
||||
const session = extractCookieValueFromSetCookie(result.headers, "session");
|
||||
if (!session) {
|
||||
// 没有 session 就不能恢复 NewAPI 登录态,必须让前端明确感知失败。
|
||||
clearNewApiAuthCookies(event);
|
||||
return createErrorResponse(
|
||||
500,
|
||||
"登录成功但未收到 NewAPI session,请稍后重试"
|
||||
);
|
||||
}
|
||||
|
||||
setNewApiAuthCookies(
|
||||
event,
|
||||
session,
|
||||
normalized.user.id,
|
||||
extractCookieMetaFromSetCookie(result.headers, "session")
|
||||
);
|
||||
|
||||
return createSuccessResponse<IUserLoginData>(
|
||||
normalized.user,
|
||||
normalized.message
|
||||
);
|
||||
} catch (error) {
|
||||
clearNewApiAuthCookies(event);
|
||||
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.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
|
||||
};
|
||||
};
|
||||
|
||||
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"
|
||||
);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return value !== null && typeof value === "object";
|
||||
};
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
// server/api/auth/logout.get.ts
|
||||
import type { IUserLogoutData } from "#shared/types";
|
||||
import {
|
||||
clearNewApiAuthCookies,
|
||||
createSuccessResponse,
|
||||
createUpstreamErrorResponse,
|
||||
newApiFetch
|
||||
newApiAuthedFetch
|
||||
} from "~~/server/utils";
|
||||
|
||||
/**
|
||||
* GET /api/auth/logout
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await newApiFetch<IUserLogoutData>("/api/user/logout", {
|
||||
// 带上本站 httpOnly cookie 中的 NewAPI 登录态,请求上游销毁 session。
|
||||
await newApiAuthedFetch<IUserLogoutData>(event, "/api/user/logout", {
|
||||
method: "GET"
|
||||
});
|
||||
|
||||
return createSuccessResponse<IUserLogoutData>(null, "登出成功");
|
||||
} catch (error) {
|
||||
return createUpstreamErrorResponse(error, "登出失败");
|
||||
} catch {
|
||||
// 上游 session 可能已失效,本地仍然必须清理,避免残留坏登录态。
|
||||
} finally {
|
||||
clearNewApiAuthCookies(event);
|
||||
}
|
||||
|
||||
return createSuccessResponse<IUserLogoutData>(null, "登出成功");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { IUserMeData } from "#shared/types";
|
||||
import {
|
||||
clearNewApiAuthCookies,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
newApiAuthedFetch
|
||||
} from "~~/server/utils";
|
||||
|
||||
interface INewApiWrappedUserResponse {
|
||||
/** 获取成功时上游返回的当前用户信息 */
|
||||
data?: INewApiSelfUserData | null;
|
||||
/** 上游提示信息,失败时优先返回给前端 */
|
||||
message?: string;
|
||||
/** 上游业务成功状态 */
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
type NewApiUserResponse = INewApiSelfUserData | INewApiWrappedUserResponse;
|
||||
|
||||
interface INewApiSelfUserData extends IUserMeData {
|
||||
/** NewAPI /api/user/self 可能返回配额、设置、第三方绑定等额外字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 从本站 httpOnly cookie 读取登录态,并转发给 NewAPI 查询当前用户。
|
||||
const result = await newApiAuthedFetch<NewApiUserResponse>(
|
||||
event,
|
||||
"/api/user/self",
|
||||
{
|
||||
method: "GET"
|
||||
}
|
||||
);
|
||||
const normalized = normalizeUserResponse(result);
|
||||
|
||||
if (!normalized.user) {
|
||||
// 上游业务失败代表当前 cookie 不再可靠,清理后返回统一 401。
|
||||
clearNewApiAuthCookies(event);
|
||||
return createErrorResponse(401, normalized.message);
|
||||
}
|
||||
|
||||
return createSuccessResponse<IUserMeData>(
|
||||
normalized.user,
|
||||
normalized.message
|
||||
);
|
||||
} catch {
|
||||
// cookie 缺失或上游鉴权异常时统一视为未登录。
|
||||
clearNewApiAuthCookies(event);
|
||||
return createErrorResponse(401, "未登录");
|
||||
}
|
||||
});
|
||||
|
||||
/** 兼容上游直接返回用户对象和 { success, message, data } 包装结构 */
|
||||
const normalizeUserResponse = (
|
||||
response: NewApiUserResponse
|
||||
): { message: string; user: IUserMeData | null } => {
|
||||
if (isUser(response)) {
|
||||
return {
|
||||
message: "获取用户信息成功",
|
||||
user: pickUserBasicData(response)
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(response)) {
|
||||
return {
|
||||
message: "未登录",
|
||||
user: null
|
||||
};
|
||||
}
|
||||
|
||||
const wrapped = response as INewApiWrappedUserResponse;
|
||||
const message =
|
||||
wrapped.message || (wrapped.success ? "获取用户信息成功" : "未登录");
|
||||
|
||||
if (wrapped.success !== true || !isUser(wrapped.data)) {
|
||||
return {
|
||||
message,
|
||||
user: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message,
|
||||
user: pickUserBasicData(wrapped.data)
|
||||
};
|
||||
};
|
||||
|
||||
/** 只返回前端需要的基础用户字段,避免泄露上游完整 self 信息 */
|
||||
const pickUserBasicData = (user: INewApiSelfUserData): IUserMeData => {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
group: user.group,
|
||||
role: user.role,
|
||||
status: user.status
|
||||
};
|
||||
};
|
||||
|
||||
const isUser = (value: unknown): value is INewApiSelfUserData => {
|
||||
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"
|
||||
);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return value !== null && typeof value === "object";
|
||||
};
|
||||
+77
-2
@@ -1,6 +1,23 @@
|
||||
/** 上游 NewAPI 服务的根地址 */
|
||||
import type { H3Event } from "h3";
|
||||
import { createError, getCookie } from "h3";
|
||||
import {
|
||||
NEWAPI_SESSION_COOKIE,
|
||||
NEWAPI_USER_ID_COOKIE
|
||||
} from "~~/server/utils/newApiAuthCookies";
|
||||
|
||||
/** 上游 NewAPI 服务的根地址,只在这里维护一次 */
|
||||
const BASE_URL = "https://api.qflink.xyz";
|
||||
|
||||
type FetchOptions = Parameters<typeof $fetch>[1];
|
||||
type FetchHeaders = HeadersInit | undefined;
|
||||
|
||||
interface INewApiRawResponse<T> {
|
||||
/** 上游响应 body,ofetch raw response 使用 _data 保存解析后的 body */
|
||||
_data?: T;
|
||||
/** 上游响应头,登录时需要从 Set-Cookie 中提取 session */
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上游 NewAPI 发起请求
|
||||
*
|
||||
@@ -16,10 +33,68 @@ const BASE_URL = "https://api.qflink.xyz";
|
||||
*/
|
||||
export const newApiFetch = <T>(
|
||||
path: string,
|
||||
options?: Parameters<typeof $fetch>[1]
|
||||
options?: FetchOptions
|
||||
): Promise<T> => {
|
||||
return $fetch<T>(path, {
|
||||
baseURL: BASE_URL,
|
||||
...options
|
||||
}) as Promise<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 向上游 NewAPI 发起请求,并保留响应头。
|
||||
*
|
||||
* 仅在登录这类需要读取 Set-Cookie 的场景使用;普通业务请求继续用
|
||||
* newApiFetch,保证上游请求入口仍然集中在本文件。
|
||||
*/
|
||||
export const newApiFetchRaw = async <T>(
|
||||
path: string,
|
||||
options?: FetchOptions
|
||||
): Promise<INewApiRawResponse<T>> => {
|
||||
const response = await $fetch.raw<T>(path, {
|
||||
baseURL: BASE_URL,
|
||||
...options
|
||||
});
|
||||
|
||||
return {
|
||||
_data: response._data as T | undefined,
|
||||
headers: response.headers
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 向上游 NewAPI 发起已登录请求。
|
||||
*
|
||||
* 前端不读取 NewAPI session;这里只在 Nuxt server 内从本站 httpOnly
|
||||
* cookie 读取 session/userId,并按 NewAPI 要求补齐 Cookie 与 New-Api-User。
|
||||
*/
|
||||
export const newApiAuthedFetch = <T>(
|
||||
event: H3Event,
|
||||
path: string,
|
||||
options?: FetchOptions
|
||||
): Promise<T> => {
|
||||
const session = getCookie(event, NEWAPI_SESSION_COOKIE);
|
||||
const userId = getCookie(event, NEWAPI_USER_ID_COOKIE);
|
||||
|
||||
if (!session || !userId) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "未登录"
|
||||
});
|
||||
}
|
||||
|
||||
return newApiFetch<T>(path, {
|
||||
...options,
|
||||
headers: {
|
||||
...normalizeHeaders(options?.headers),
|
||||
Cookie: `session=${session}`,
|
||||
"New-Api-User": userId
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 归一化调用方 headers,追加鉴权头时不丢失已有 Content-Type */
|
||||
const normalizeHeaders = (headers: FetchHeaders) => {
|
||||
if (!headers) return {};
|
||||
return Object.fromEntries(new Headers(headers).entries());
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./createApiResponse";
|
||||
export * from "./fetch";
|
||||
export * from "./newApiAuthCookies";
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { H3Event } from "h3";
|
||||
import { deleteCookie, setCookie } from "h3";
|
||||
|
||||
/** 上游没有声明 session 过期时间时,本地登录态默认保留 3 天 */
|
||||
const DEFAULT_AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 3;
|
||||
|
||||
/** 本站保存 NewAPI session 的 httpOnly cookie 名称 */
|
||||
export const NEWAPI_SESSION_COOKIE = "newapi_session";
|
||||
/** 本站保存 NewAPI 用户 ID 的 httpOnly cookie 名称,用于服务端组装 New-Api-User */
|
||||
export const NEWAPI_USER_ID_COOKIE = "newapi_user_id";
|
||||
|
||||
type CookieOptions = NonNullable<Parameters<typeof setCookie>[3]>;
|
||||
|
||||
export interface INewApiCookieMeta {
|
||||
/** 上游 session cookie 的 Max-Age,单位秒 */
|
||||
maxAge?: number;
|
||||
/** 上游 session cookie 的 Expires 过期时间 */
|
||||
expires?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上游 Set-Cookie 响应头中提取指定 cookie 的值。
|
||||
*
|
||||
* 只返回 cookie value,不返回 Path/Expires 等属性,避免把元数据写进
|
||||
* 本站 newapi_session。
|
||||
*/
|
||||
export const extractCookieValueFromSetCookie = (
|
||||
headers: Headers,
|
||||
name: string
|
||||
): string | null => {
|
||||
const setCookie = findSetCookie(headers, name);
|
||||
if (!setCookie) return null;
|
||||
|
||||
const firstPart = setCookie.split(";")[0] ?? "";
|
||||
const equalsIndex = firstPart.indexOf("=");
|
||||
if (equalsIndex === -1) return null;
|
||||
|
||||
return firstPart.slice(equalsIndex + 1).trim() || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从上游 Set-Cookie 中读取过期时间元数据。
|
||||
*
|
||||
* 本站 cookie 优先沿用上游 Max-Age/Expires;没有则在写入时默认 3 天。
|
||||
*/
|
||||
export const extractCookieMetaFromSetCookie = (
|
||||
headers: Headers,
|
||||
name: string
|
||||
): INewApiCookieMeta | undefined => {
|
||||
const setCookie = findSetCookie(headers, name);
|
||||
if (!setCookie) return undefined;
|
||||
|
||||
const meta: INewApiCookieMeta = {};
|
||||
for (const part of setCookie.split(";").slice(1)) {
|
||||
const [rawKey, ...rawValue] = part.trim().split("=");
|
||||
const key = rawKey?.toLowerCase();
|
||||
const value = rawValue.join("=");
|
||||
|
||||
if (key === "max-age") {
|
||||
const maxAge = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(maxAge)) meta.maxAge = maxAge;
|
||||
}
|
||||
|
||||
if (key === "expires") {
|
||||
const expires = new Date(value);
|
||||
if (!Number.isNaN(expires.getTime())) meta.expires = expires;
|
||||
}
|
||||
}
|
||||
|
||||
return meta.maxAge !== undefined || meta.expires ? meta : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 写入本站认证 cookie。
|
||||
*
|
||||
* session 与 userId 都设置为 httpOnly,前端只通过 /api/auth/me 恢复状态,
|
||||
* 不直接接触 NewAPI session 明文。
|
||||
*/
|
||||
export const setNewApiAuthCookies = (
|
||||
event: H3Event,
|
||||
session: string,
|
||||
userId: number | string,
|
||||
upstreamCookieMeta?: INewApiCookieMeta
|
||||
) => {
|
||||
const options = buildAuthCookieOptions(upstreamCookieMeta);
|
||||
|
||||
setCookie(event, NEWAPI_SESSION_COOKIE, session, options);
|
||||
setCookie(event, NEWAPI_USER_ID_COOKIE, String(userId), options);
|
||||
};
|
||||
|
||||
/** 清理本站保存的 NewAPI 登录态,登出和鉴权失败时都调用 */
|
||||
export const clearNewApiAuthCookies = (event: H3Event) => {
|
||||
const options: CookieOptions = {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production"
|
||||
};
|
||||
|
||||
deleteCookie(event, NEWAPI_SESSION_COOKIE, options);
|
||||
deleteCookie(event, NEWAPI_USER_ID_COOKIE, options);
|
||||
};
|
||||
|
||||
/** 统一生成本站认证 cookie 选项,保证安全属性在两个 cookie 上一致 */
|
||||
const buildAuthCookieOptions = (meta?: INewApiCookieMeta): CookieOptions => {
|
||||
const options: CookieOptions = {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production"
|
||||
};
|
||||
|
||||
if (typeof meta?.maxAge === "number") {
|
||||
options.maxAge = meta.maxAge;
|
||||
} else if (meta?.expires) {
|
||||
options.expires = meta.expires;
|
||||
} else {
|
||||
options.maxAge = DEFAULT_AUTH_COOKIE_MAX_AGE;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
/** 在多个 Set-Cookie 值中找到指定名称的 cookie */
|
||||
const findSetCookie = (headers: Headers, name: string): string | null => {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
for (const setCookie of getSetCookieValues(headers)) {
|
||||
const firstPart = setCookie.split(";")[0] ?? "";
|
||||
const equalsIndex = firstPart.indexOf("=");
|
||||
if (equalsIndex === -1) continue;
|
||||
|
||||
const cookieName = firstPart.slice(0, equalsIndex).trim().toLowerCase();
|
||||
if (cookieName === lowerName) return setCookie;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取 Set-Cookie 头。
|
||||
*
|
||||
* Nuxt 服务端运行时优先使用 getSetCookie;没有该方法时读取合并后的
|
||||
* set-cookie 字符串再拆分。
|
||||
*/
|
||||
const getSetCookieValues = (headers: Headers): string[] => {
|
||||
const headersWithSetCookie = headers as Headers & {
|
||||
getSetCookie?: () => string[];
|
||||
};
|
||||
const values = headersWithSetCookie.getSetCookie?.();
|
||||
if (values?.length) return values;
|
||||
|
||||
const headerValue = headers.get("set-cookie");
|
||||
return headerValue ? splitSetCookieHeader(headerValue) : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 拆分被合并成一个字符串的 Set-Cookie。
|
||||
*
|
||||
* Expires 属性本身包含逗号,不能简单按逗号 split;这里只在后续片段
|
||||
* 看起来像新 cookie 名称时才切分。
|
||||
*/
|
||||
const splitSetCookieHeader = (headerValue: string): string[] => {
|
||||
const cookies: string[] = [];
|
||||
let start = 0;
|
||||
|
||||
for (let index = 0; index < headerValue.length; index += 1) {
|
||||
if (headerValue[index] !== ",") continue;
|
||||
|
||||
const rest = headerValue.slice(index + 1);
|
||||
if (/^\s*[^=;,\s]+=/.test(rest)) {
|
||||
cookies.push(headerValue.slice(start, index).trim());
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
cookies.push(headerValue.slice(start).trim());
|
||||
return cookies.filter(Boolean);
|
||||
};
|
||||
+14
-2
@@ -33,9 +33,10 @@ export interface IUserLoginRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功后返回的用户信息。
|
||||
* 前端可见的基础用户信息。
|
||||
* 只保留页面判断登录态和展示身份所需字段,不透出配额、设置、第三方绑定等上游内部信息。
|
||||
*/
|
||||
export interface IUserLoginData {
|
||||
export interface IUserBasicData {
|
||||
/** 用户 ID */
|
||||
id: number;
|
||||
/** 用户名 */
|
||||
@@ -50,6 +51,17 @@ export interface IUserLoginData {
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功后返回给前端的用户信息。
|
||||
*/
|
||||
export type IUserLoginData = IUserBasicData;
|
||||
|
||||
/**
|
||||
* 获取当前用户信息成功时返回给前端的数据。
|
||||
* 服务端会从 NewAPI /api/user/self 的完整用户对象中裁剪为 IUserBasicData。
|
||||
*/
|
||||
export type IUserMeData = IUserBasicData;
|
||||
|
||||
/**
|
||||
* 登出成功时上游接口返回的数据类型。
|
||||
* NewAPI 登出接口通常无业务数据返回,此处用 null 表示。
|
||||
|
||||
Reference in New Issue
Block a user