Files
2026-03-31 12:44:54 -05:00

502 lines
14 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;
error?: string;
effectiveUserId?: string;
anonymousUserId?: string;
token?: string;
record?: PBAuthRecord;
};
type RecoveryResult = {
vip: boolean;
matchedUserId?: string;
anonymousUserId?: string;
source?: string;
};
const inflightEmailChecks = new Map<string, Promise<PayjsVipEmailResult | null>>();
const PAYJS_CHECK_BY_EMAIL_TIMEOUT =
Number(process.env.PAYJSAPI_CHECK_BY_EMAIL_TIMEOUT) || 20_000;
const PAYJS_CHECK_BY_EMAIL_RETRIES =
Math.max(0, Number(process.env.PAYJSAPI_CHECK_BY_EMAIL_RETRIES) || 1);
const PB_USER_LOGIN_TIMEOUT_MS =
Number(process.env.POCKETBASE_USER_LOGIN_TIMEOUT_MS) ||
(process.env.NODE_ENV === "development" ? 30_000 : 15_000);
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(/\/$/, "");
}
function getLocalPayApiUrl(): string {
return (
process.env.PAYMENT_API_INTERNAL_URL?.trim() ||
"http://127.0.0.1:8007"
).replace(/\/$/, "");
}
async function checkVipByEmailFromPayjsapi(
request: NextRequest,
email: string,
password: string,
userId?: string,
wechatUserId?: string
): Promise<PayjsVipEmailResult | null> {
const inflightKey = `${email}::${userId || ""}::${wechatUserId || ""}`;
const existed = inflightEmailChecks.get(inflightKey);
if (existed) {
return existed;
}
const task = (async () => {
const payConfig = getPaymentConfig();
const primaryUrl = resolvePayApiUrl(request);
const localUrl = getLocalPayApiUrl();
const candidates = Array.from(
new Set(
[localUrl, primaryUrl, payConfig.apiUrl]
.map((x) => (x || "").trim().replace(/\/$/, ""))
.filter(Boolean)
)
);
for (const apiUrl of candidates) {
for (let i = 0; i <= PAYJS_CHECK_BY_EMAIL_RETRIES; i += 1) {
try {
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 } : {}),
...(wechatUserId ? { wechat_userid: wechatUserId } : {}),
}),
cache: "no-store",
signal: AbortSignal.timeout(PAYJS_CHECK_BY_EMAIL_TIMEOUT),
});
if (!res.ok) {
continue;
}
const data = await res.json().catch(() => ({}));
if (!data?.vip) {
return {
vip: false,
error:
typeof data?.error === "string" && data.error.trim()
? data.error.trim()
: undefined,
};
}
return {
vip: true,
effectiveUserId:
typeof data?.effectiveUserId === "string"
? data.effectiveUserId.trim()
: undefined,
anonymousUserId:
typeof data?.anonymousUserId === "string"
? data.anonymousUserId.trim()
: undefined,
token: typeof data?.token === "string" ? data.token : undefined,
record: data?.record?.id ? data.record : undefined,
};
} catch (error) {
if (i >= PAYJS_CHECK_BY_EMAIL_RETRIES) {
console.log("[check-by-email] payjsapi request failed url=%s error=%o", apiUrl, error);
} else {
await new Promise((resolve) => setTimeout(resolve, 250 * (i + 1)));
}
}
}
}
return null;
})();
inflightEmailChecks.set(inflightKey, task);
try {
return await task;
} finally {
inflightEmailChecks.delete(inflightKey);
}
}
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",
signal: AbortSignal.timeout(PB_USER_LOGIN_TIMEOUT_MS),
});
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)) {
const requestUserHasVip = await hasVipByUserId(adminToken, requestedAnonymousUserId);
if (requestUserHasVip) {
return {
vip: true,
matchedUserId: requestedAnonymousUserId,
anonymousUserId: requestedAnonymousUserId,
source: "request_user_id",
};
}
}
const [linkRecord, fallbackAnonymousUserId] = await Promise.all([
findVipEmailLinkByEmail(adminToken, email),
findAnonymousUserIdForUser(adminToken, pbUserId),
]);
const linkedAnonymousUserId =
typeof linkRecord?.anonymous_user_id === "string"
? linkRecord.anonymous_user_id.trim()
: fallbackAnonymousUserId ?? "";
const linkedIds = Array.from(
new Set(
[
linkedAnonymousUserId,
typeof linkRecord?.user_id === "string" ? linkRecord.user_id.trim() : "",
pbUserId,
].filter(Boolean)
)
);
if (linkedIds.length) {
const linkedVipChecks = await Promise.all(
linkedIds.map(async (linkedId) => ({
linkedId,
vip: await hasVipByUserId(adminToken, linkedId),
}))
);
const hit = linkedVipChecks.find((item) => item.vip);
if (hit) {
return {
vip: true,
matchedUserId: hit.linkedId,
anonymousUserId: isAnonymousUserId(linkedAnonymousUserId)
? linkedAnonymousUserId
: isAnonymousUserId(hit.linkedId)
? hit.linkedId
: undefined,
source: "vip_email_link",
};
}
}
const pbUserHasVip = await hasVipByUserId(adminToken, pbUserId);
if (pbUserHasVip) {
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)
)
);
if (payjsCandidates.length) {
const payjsVipChecks = await Promise.all(
payjsCandidates.map(async (candidate) => ({
candidate,
vip: await hasVipByUserId(adminToken, candidate),
}))
);
const hit = payjsVipChecks.find((item) => item.vip);
if (hit) {
return {
vip: true,
matchedUserId: hit.candidate,
anonymousUserId: isAnonymousUserId(hit.candidate)
? hit.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();
const wechatUserId = String(
body?.wechat_userid ?? body?.userid ?? ""
).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 adminTokenPromise = getAdminToken().catch((error) => {
console.error("[check-by-email] getAdminToken failed:", error);
return null;
});
let authData: { token: string; record: PBAuthRecord } | null = null;
let loginError: string | null = null;
try {
authData = await loginPocketBaseUser(email, password);
} catch (error) {
loginError = error instanceof Error ? error.message : "登录失败";
}
let payjsResult: PayjsVipEmailResult | null = null;
if (!authData) {
payjsResult = await checkVipByEmailFromPayjsapi(
request,
email,
password,
requestedAnonymousUserId || undefined,
wechatUserId || undefined
);
// PB 登录失败时,尝试使用 payjsapi 返回的 token/record 兜底恢复
if (payjsResult?.vip && payjsResult.token && payjsResult.record?.id) {
console.log("[check-by-email] fallback by payjsapi token email=%s", email);
return NextResponse.json({
ok: true,
vip: true,
token: payjsResult.token,
record: payjsResult.record,
effectiveUserId: payjsResult.record.id,
anonymousUserId: payjsResult.anonymousUserId ?? null,
memosAccount: payjsResult.anonymousUserId ?? null,
source: "payjsapi_token_fallback",
});
}
const msg = loginError || "登录失败";
return NextResponse.json(
{ ok: false, vip: false, error: msg },
{ status: msg.includes("密码") || msg.includes("邮箱") ? 401 : 503 }
);
}
const adminToken = await adminTokenPromise;
if (!adminToken) {
// 管理员 token 获取失败时,降级到 payjsapi 结果,避免直接 500
if (!payjsResult) {
payjsResult = await checkVipByEmailFromPayjsapi(
request,
email,
password,
requestedAnonymousUserId || undefined,
wechatUserId || undefined
);
}
if (payjsResult?.vip) {
return NextResponse.json({
ok: true,
vip: true,
token: authData.token,
record: authData.record,
effectiveUserId: authData.record.id,
anonymousUserId: payjsResult.anonymousUserId ?? null,
memosAccount: payjsResult.anonymousUserId ?? null,
source: "payjsapi_degraded",
});
}
return NextResponse.json(
{
ok: false,
vip: false,
error:
payjsResult?.error ||
"服务繁忙,请稍后重试",
},
{ status: 503 }
);
}
let recovery = await resolveRecovery(
adminToken,
email,
authData.record.id,
requestedAnonymousUserId || undefined,
payjsResult
);
if (!recovery.vip) {
payjsResult = await checkVipByEmailFromPayjsapi(
request,
email,
password,
requestedAnonymousUserId || undefined,
wechatUserId || 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: payjsResult?.error ||
"未找到关联的会员记录,请确认已支付并绑定邮箱;如为历史匿名订单,可填写支付时的 user_id 再登录",
});
}
let fallbackAnonymousUserId: string | undefined;
if (
isAnonymousUserId(requestedAnonymousUserId) &&
(await hasVipByUserId(adminToken, requestedAnonymousUserId))
) {
fallbackAnonymousUserId = requestedAnonymousUserId;
} else {
fallbackAnonymousUserId =
(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
);
if (wechatUserId) {
const apiUrl = resolvePayApiUrl(request);
await fetch(`${apiUrl}/nomadvip/wechat_profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: anonymousUserId || authData.record.id,
email,
wechat_userid: wechatUserId,
}),
cache: "no-store",
}).catch(() => null);
}
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 }
);
}
}