import { NextRequest, NextResponse } from "next/server"; import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; import { checkPaymentsByUserId, checkSiteVipByUserId, findLinkedMemberFromOrder, findVipEmailLinkByAnonymousUserId, getAdminToken, isAnonymousUserId, upsertVipEmailLink, } from "@/app/api/vip/_helpers"; type PayjsVipResult = { vip: boolean; email?: string; linkedUserId?: string; }; const inflightUserChecks = new Map>(); type UserCheckPayload = { ok: boolean; vip: boolean; alreadyLinked: boolean; /** 服务端异常/超时时为 true,前端勿据此清空本地已登录 VIP */ uncertain?: boolean; email?: string; linkedUserId?: string; }; const inflightUserRequests = new Map< string, Promise >(); const userCheckResultCache = new Map< string, { expiresAt: number; payload: UserCheckPayload; } >(); const USER_CHECK_CACHE_TTL_MS = Number(process.env.CHECK_BY_USER_CACHE_TTL_MS) || 10_000; async function fetchPayjsVipWithRetry( url: string, timeoutMs: number, retries: number ): Promise { let lastError: unknown = null; for (let i = 0; i <= retries; i += 1) { try { const res = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(timeoutMs), }); return res; } catch (error) { lastError = error; if (i < retries) { await new Promise((resolve) => setTimeout(resolve, 250 * (i + 1))); } } } throw lastError instanceof Error ? lastError : new Error("payjsapi fetch failed"); } 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 checkVipFromPayjsapi( request: NextRequest, userId: string ): Promise { const existed = inflightUserChecks.get(userId); if (existed) { return existed; } const task = (async () => { try { const apiUrl = resolvePayApiUrl(request); const url = `${apiUrl}/nomadvip/check_vip?user_id=${encodeURIComponent( userId )}`; console.log("[check-by-user] payjsapi request user_id=%s url=%s", userId, url); const checkTimeout = Number(process.env.PAYJSAPI_CHECK_TIMEOUT) || 15_000; const retries = Math.max(0, Number(process.env.PAYJSAPI_CHECK_RETRIES) || 1); const res = await fetchPayjsVipWithRetry(url, checkTimeout, retries); if (!res) { return null; } if (!res.ok) { console.log( "[check-by-user] payjsapi non-ok user_id=%s status=%s", userId, res.status ); return null; } const data = await res.json().catch(() => ({})); const result = { vip: !!data?.vip, email: typeof data?.email === "string" && data.email.trim() ? data.email.trim().toLowerCase() : undefined, linkedUserId: typeof data?.linked_user_id === "string" && data.linked_user_id.trim() ? data.linked_user_id.trim() : undefined, }; console.log( "[check-by-user] payjsapi response user_id=%s vip=%s email=%s", userId, result.vip, result.email ?? "(none)" ); return result; } catch (error) { console.log( "[check-by-user] payjsapi request failed user_id=%s error=%s", userId, error instanceof Error ? error.message : String(error) ); return null; } })(); inflightUserChecks.set(userId, task); try { return await task; } finally { inflightUserChecks.delete(userId); } } export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})); const userId = String(body?.user_id ?? body?.userId ?? "").trim(); console.log("[check-by-user] request user_id=%s", userId || "(empty)"); if (!userId) { return NextResponse.json( { ok: false, vip: false, error: "缺少 user_id" }, { status: 400 } ); } const cached = userCheckResultCache.get(userId); if (cached && cached.expiresAt > Date.now()) { return NextResponse.json(cached.payload); } if (cached) { userCheckResultCache.delete(userId); } const inflight = inflightUserRequests.get(userId); if (inflight) { const payload = await inflight; return NextResponse.json(payload); } const task = (async () => { let payjsResult: PayjsVipResult | null = null; let vip = false; let email: string | null = null; let linkedUserId: string | undefined; let vipFromSite = false; let vipFromPayments = false; let vipSource = "none"; try { const adminToken = await getAdminToken().catch((error) => { console.error("[check-by-user] getAdminToken failed:", error); return null; }); if (!adminToken) { payjsResult = await checkVipFromPayjsapi(request, userId); vip = !!payjsResult?.vip; email = payjsResult?.email ?? null; console.error("[check-by-user] missing PocketBase admin token, fallback to payjs result"); return { ok: true, vip, alreadyLinked: !!email, email: email ?? undefined, }; } if (!vip) { vipFromSite = await checkSiteVipByUserId(adminToken, userId); vip = vipFromSite; if (!vip) { vipFromPayments = await checkPaymentsByUserId(adminToken, userId); vip = vipFromPayments; } if (vipFromSite) { vipSource = "pocketbase:site_vip"; } else if (vipFromPayments) { vipSource = "pocketbase:payments"; } console.log( "[check-by-user] pb fallback user_id=%s site_vip=%s payments=%s", userId, vipFromSite, vipFromPayments ); } // 同机 PB 命中则不再打 payjsapi;仅未命中时再走 payjs(与线上极致路径一致) if (!vip) { payjsResult = await checkVipFromPayjsapi(request, userId); if (payjsResult?.vip) { vip = true; email = payjsResult.email ?? null; linkedUserId = payjsResult.linkedUserId; vipSource = "payjsapi"; } } if (isAnonymousUserId(userId)) { const linkRecord = await findVipEmailLinkByAnonymousUserId(adminToken, userId); const linkEmail = typeof linkRecord?.email === "string" ? linkRecord.email.trim().toLowerCase() : ""; const linkUserId = typeof linkRecord?.user_id === "string" ? linkRecord.user_id.trim() : ""; // 匿名 user_id 本身可能已迁移到真实用户,需补查映射用户的 VIP if (!vip && linkUserId && linkUserId !== userId) { const linkedVip = await checkSiteVipByUserId(adminToken, linkUserId); const linkedPaymentVip = linkedVip ? true : await checkPaymentsByUserId(adminToken, linkUserId); if (linkedVip || linkedPaymentVip) { vip = true; linkedUserId = linkUserId; vipSource = linkedVip ? "vip_email_link:site_vip" : "vip_email_link:payments"; } } if (vip && linkEmail) { email = linkEmail; if (linkUserId && !isAnonymousUserId(linkUserId)) { linkedUserId = linkUserId; } if (!vipSource || vipSource === "none") { vipSource = "vip_email_link"; } console.log( "[check-by-user] linked by vip_email_link user_id=%s linkedUserId=%s email=%s", userId, linkedUserId || "(none)", email ); } else if (vip) { const linkedMember = await findLinkedMemberFromOrder(adminToken, userId); if (linkedMember) { email = linkedMember.email; linkedUserId = linkedMember.userId; await upsertVipEmailLink( adminToken, linkedMember.email, linkedMember.userId, userId ); vipSource = "linked_from_order"; console.log( "[check-by-user] linked by order user_id=%s linkedUserId=%s email=%s", userId, linkedUserId, email ); } } } console.log( "[check-by-user] result user_id=%s vip=%s source=%s alreadyLinked=%s linkedUserId=%s", userId, vip, vipSource, !!email, linkedUserId || "(none)" ); return { ok: true, vip, alreadyLinked: !!email, email: email ?? undefined, linkedUserId, }; } catch (error) { console.error("[check-by-user] failed:", error); // 勿伪装成「确定非 VIP」,否则微信/H5 弱网会误清本地态 return { ok: false, vip: false, alreadyLinked: false, uncertain: true, }; } })(); inflightUserRequests.set(userId, task); try { const payload = await task; // 只缓存命中结果,避免绑定邮箱后的短时 false 缓存导致需要多次刷新才恢复 if (!payload.uncertain && payload.vip) { userCheckResultCache.set(userId, { payload, expiresAt: Date.now() + USER_CHECK_CACHE_TTL_MS, }); } return NextResponse.json(payload); } finally { inflightUserRequests.delete(userId); } }