Files
gitlab-instance-0a899031_no…/app/api/vip/check-by-user/route.ts
2026-03-27 19:07:24 -05:00

312 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};
const inflightUserChecks = new Map<string, Promise<PayjsVipResult | null>>();
type UserCheckPayload = {
ok: boolean;
vip: boolean;
alreadyLinked: boolean;
email?: string;
linkedUserId?: string;
};
const inflightUserRequests = new Map<
string,
Promise<UserCheckPayload>
>();
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<Response | null> {
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<PayjsVipResult | null> {
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,
};
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;
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);
// 接口容错:即使 PB 回退失败,也不抛 500 给前端
return { ok: true, vip: false, alreadyLinked: false };
}
})();
inflightUserRequests.set(userId, task);
try {
const payload = await task;
userCheckResultCache.set(userId, {
payload,
expiresAt: Date.now() + USER_CHECK_CACHE_TTL_MS,
});
return NextResponse.json(payload);
} finally {
inflightUserRequests.delete(userId);
}
}