328 lines
8.5 KiB
TypeScript
328 lines
8.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import {
|
|
getApiUrlFromRequest,
|
|
getPaymentConfig,
|
|
getPocketBaseConfig,
|
|
} from "@/config/services.config";
|
|
import {
|
|
ensureSiteVipForUser,
|
|
findAnonymousUserIdForUser,
|
|
findVipEmailLinkByEmail,
|
|
getAdminToken,
|
|
hasVipByUserId,
|
|
isAnonymousUserId,
|
|
upsertVipEmailLink,
|
|
} from "@/app/api/vip/_helpers";
|
|
|
|
type PBAuthRecord = {
|
|
id: string;
|
|
email?: string;
|
|
};
|
|
|
|
type PayjsVipEmailResult = {
|
|
vip: boolean;
|
|
effectiveUserId?: string;
|
|
anonymousUserId?: string;
|
|
};
|
|
|
|
type RecoveryResult = {
|
|
vip: boolean;
|
|
matchedUserId?: string;
|
|
anonymousUserId?: string;
|
|
source?: string;
|
|
};
|
|
|
|
function resolvePayApiUrl(request: NextRequest): string {
|
|
const payConfig = getPaymentConfig();
|
|
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
|
|
return (
|
|
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
|
|
payConfig.apiUrl
|
|
).replace(/\/$/, "");
|
|
}
|
|
|
|
async function checkVipByEmailFromPayjsapi(
|
|
request: NextRequest,
|
|
email: string,
|
|
password: string,
|
|
userId?: string
|
|
): Promise<PayjsVipEmailResult | null> {
|
|
try {
|
|
const apiUrl = resolvePayApiUrl(request);
|
|
const res = await fetch(`${apiUrl}/nomadvip/check_vip_by_email`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
email,
|
|
password,
|
|
...(userId ? { user_id: userId } : {}),
|
|
}),
|
|
cache: "no-store",
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
if (!res.ok) {
|
|
return null;
|
|
}
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!data?.vip) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
vip: true,
|
|
effectiveUserId:
|
|
typeof data?.effectiveUserId === "string"
|
|
? data.effectiveUserId.trim()
|
|
: undefined,
|
|
anonymousUserId:
|
|
typeof data?.anonymousUserId === "string"
|
|
? data.anonymousUserId.trim()
|
|
: undefined,
|
|
};
|
|
} catch (error) {
|
|
console.log("[check-by-email] payjsapi request failed:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loginPocketBaseUser(
|
|
email: string,
|
|
password: string
|
|
): Promise<{ token: string; record: PBAuthRecord }> {
|
|
const pb = getPocketBaseConfig();
|
|
const res = await fetch(`${pb.url}/api/collections/users/auth-with-password`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ identity: email, password }),
|
|
cache: "no-store",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const error = await res.json().catch(() => ({}));
|
|
throw new Error(error?.message || "邮箱或密码错误");
|
|
}
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!data?.token || !data?.record?.id) {
|
|
throw new Error("登录失败");
|
|
}
|
|
|
|
return {
|
|
token: data.token,
|
|
record: data.record,
|
|
};
|
|
}
|
|
|
|
async function resolveRecovery(
|
|
adminToken: string,
|
|
email: string,
|
|
pbUserId: string,
|
|
requestedAnonymousUserId?: string,
|
|
payjsResult?: PayjsVipEmailResult | null
|
|
): Promise<RecoveryResult> {
|
|
if (
|
|
isAnonymousUserId(requestedAnonymousUserId) &&
|
|
(await hasVipByUserId(adminToken, requestedAnonymousUserId))
|
|
) {
|
|
return {
|
|
vip: true,
|
|
matchedUserId: requestedAnonymousUserId,
|
|
anonymousUserId: requestedAnonymousUserId,
|
|
source: "request_user_id",
|
|
};
|
|
}
|
|
|
|
const linkRecord = await findVipEmailLinkByEmail(adminToken, email);
|
|
const linkedAnonymousUserId =
|
|
typeof linkRecord?.anonymous_user_id === "string"
|
|
? linkRecord.anonymous_user_id.trim()
|
|
: (await findAnonymousUserIdForUser(adminToken, pbUserId)) ?? "";
|
|
const linkedIds = Array.from(
|
|
new Set(
|
|
[
|
|
linkedAnonymousUserId,
|
|
typeof linkRecord?.user_id === "string" ? linkRecord.user_id.trim() : "",
|
|
pbUserId,
|
|
].filter(Boolean)
|
|
)
|
|
);
|
|
|
|
for (const linkedId of linkedIds) {
|
|
if (!(await hasVipByUserId(adminToken, linkedId))) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
vip: true,
|
|
matchedUserId: linkedId,
|
|
anonymousUserId: isAnonymousUserId(linkedAnonymousUserId)
|
|
? linkedAnonymousUserId
|
|
: isAnonymousUserId(linkedId)
|
|
? linkedId
|
|
: undefined,
|
|
source: "vip_email_link",
|
|
};
|
|
}
|
|
|
|
if (await hasVipByUserId(adminToken, pbUserId)) {
|
|
return {
|
|
vip: true,
|
|
matchedUserId: pbUserId,
|
|
source: "pb_user_id",
|
|
};
|
|
}
|
|
|
|
const payjsCandidates = Array.from(
|
|
new Set(
|
|
[
|
|
typeof payjsResult?.effectiveUserId === "string"
|
|
? payjsResult.effectiveUserId.trim()
|
|
: "",
|
|
typeof payjsResult?.anonymousUserId === "string"
|
|
? payjsResult.anonymousUserId.trim()
|
|
: "",
|
|
].filter(Boolean)
|
|
)
|
|
);
|
|
|
|
for (const candidate of payjsCandidates) {
|
|
if (!(await hasVipByUserId(adminToken, candidate))) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
vip: true,
|
|
matchedUserId: candidate,
|
|
anonymousUserId: isAnonymousUserId(candidate)
|
|
? candidate
|
|
: typeof payjsResult?.anonymousUserId === "string" &&
|
|
isAnonymousUserId(payjsResult.anonymousUserId.trim())
|
|
? payjsResult.anonymousUserId.trim()
|
|
: undefined,
|
|
source: "payjsapi",
|
|
};
|
|
}
|
|
|
|
return { vip: false };
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const body = await request.json().catch(() => ({}));
|
|
const email = String(body?.email ?? "").trim().toLowerCase();
|
|
const password = String(body?.password ?? "").trim();
|
|
const requestedAnonymousUserId = String(
|
|
body?.user_id ?? body?.anonymousUserId ?? ""
|
|
).trim();
|
|
|
|
console.log(
|
|
"[check-by-email] request email=%s user_id=%s",
|
|
email || "(empty)",
|
|
requestedAnonymousUserId || "(empty)"
|
|
);
|
|
|
|
if (!email || !password) {
|
|
return NextResponse.json(
|
|
{ ok: false, vip: false, error: "请填写邮箱和密码" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const authData = await loginPocketBaseUser(email, password);
|
|
const adminToken = await getAdminToken();
|
|
if (!adminToken) {
|
|
return NextResponse.json(
|
|
{ ok: false, vip: false, error: "PocketBase 管理员认证失败" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
let payjsResult: PayjsVipEmailResult | null = null;
|
|
let recovery = await resolveRecovery(
|
|
adminToken,
|
|
email,
|
|
authData.record.id,
|
|
requestedAnonymousUserId || undefined,
|
|
payjsResult
|
|
);
|
|
|
|
if (!recovery.vip) {
|
|
payjsResult = await checkVipByEmailFromPayjsapi(
|
|
request,
|
|
email,
|
|
password,
|
|
requestedAnonymousUserId || undefined
|
|
);
|
|
recovery = await resolveRecovery(
|
|
adminToken,
|
|
email,
|
|
authData.record.id,
|
|
requestedAnonymousUserId || undefined,
|
|
payjsResult
|
|
);
|
|
}
|
|
|
|
if (!recovery.vip) {
|
|
console.log(
|
|
"[check-by-email] no-vip email=%s pbUserId=%s",
|
|
email,
|
|
authData.record.id
|
|
);
|
|
return NextResponse.json({
|
|
ok: false,
|
|
vip: false,
|
|
error:
|
|
"未找到关联的会员记录,请确认已支付并绑定邮箱;如为历史匿名订单,可填写支付时的 user_id 再登录",
|
|
});
|
|
}
|
|
|
|
const fallbackAnonymousUserId = isAnonymousUserId(requestedAnonymousUserId)
|
|
? requestedAnonymousUserId
|
|
: (await findAnonymousUserIdForUser(adminToken, authData.record.id)) ||
|
|
(typeof payjsResult?.anonymousUserId === "string" &&
|
|
isAnonymousUserId(payjsResult.anonymousUserId.trim())
|
|
? payjsResult.anonymousUserId.trim()
|
|
: undefined);
|
|
|
|
const anonymousUserId =
|
|
recovery.anonymousUserId || fallbackAnonymousUserId;
|
|
|
|
if (isAnonymousUserId(anonymousUserId)) {
|
|
await ensureSiteVipForUser(adminToken, anonymousUserId, authData.record.id);
|
|
}
|
|
|
|
await upsertVipEmailLink(
|
|
adminToken,
|
|
email,
|
|
authData.record.id,
|
|
anonymousUserId
|
|
);
|
|
|
|
console.log(
|
|
"[check-by-email] success source=%s pbUserId=%s anonymousUserId=%s",
|
|
recovery.source ?? "pocketbase",
|
|
authData.record.id,
|
|
anonymousUserId ?? "(none)"
|
|
);
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
vip: true,
|
|
token: authData.token,
|
|
record: authData.record,
|
|
effectiveUserId: authData.record.id,
|
|
anonymousUserId: anonymousUserId ?? null,
|
|
memosAccount: anonymousUserId ?? null,
|
|
source: recovery.source ?? "pocketbase",
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "登录失败";
|
|
const status = message.includes("密码") || message.includes("邮箱") ? 401 : 500;
|
|
return NextResponse.json(
|
|
{ ok: false, vip: false, error: message },
|
|
{ status }
|
|
);
|
|
}
|
|
}
|