328 lines
9.5 KiB
TypeScript
328 lines
9.5 KiB
TypeScript
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<string, Promise<PayjsVipResult | null>>();
|
||
type UserCheckPayload = {
|
||
ok: boolean;
|
||
vip: boolean;
|
||
alreadyLinked: boolean;
|
||
/** 服务端异常/超时时为 true,前端勿据此清空本地已登录 VIP */
|
||
uncertain?: 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,
|
||
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);
|
||
}
|
||
}
|