502 lines
14 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|