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>(); 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 { 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 { 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 } ); } }