This commit is contained in:
eric
2026-03-26 10:57:26 -05:00
parent a94e801a95
commit eaa92ddf36
3 changed files with 171 additions and 36 deletions

View File

@@ -21,8 +21,11 @@ type PBAuthRecord = {
type PayjsVipEmailResult = { type PayjsVipEmailResult = {
vip: boolean; vip: boolean;
error?: string;
effectiveUserId?: string; effectiveUserId?: string;
anonymousUserId?: string; anonymousUserId?: string;
token?: string;
record?: PBAuthRecord;
}; };
type RecoveryResult = { type RecoveryResult = {
@@ -69,7 +72,13 @@ async function checkVipByEmailFromPayjsapi(
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!data?.vip) { if (!data?.vip) {
return null; return {
vip: false,
error:
typeof data?.error === "string" && data.error.trim()
? data.error.trim()
: undefined,
};
} }
return { return {
@@ -82,6 +91,8 @@ async function checkVipByEmailFromPayjsapi(
typeof data?.anonymousUserId === "string" typeof data?.anonymousUserId === "string"
? data.anonymousUserId.trim() ? data.anonymousUserId.trim()
: undefined, : undefined,
token: typeof data?.token === "string" ? data.token : undefined,
record: data?.record?.id ? data.record : undefined,
}; };
} catch (error) { } catch (error) {
console.log("[check-by-email] payjsapi request failed:", error); console.log("[check-by-email] payjsapi request failed:", error);
@@ -246,18 +257,74 @@ export async function POST(request: NextRequest) {
} }
try { try {
const [authData, adminToken] = await Promise.all([ let authData: { token: string; record: PBAuthRecord } | null = null;
loginPocketBaseUser(email, password), let loginError: string | null = null;
getAdminToken(), try {
]); authData = await loginPocketBaseUser(email, password);
if (!adminToken) { } catch (error) {
loginError = error instanceof Error ? error.message : "登录失败";
}
let payjsResult: PayjsVipEmailResult | null = await checkVipByEmailFromPayjsapi(
request,
email,
password,
requestedAnonymousUserId || undefined
);
// PB 登录失败时,尝试使用 payjsapi 返回的 token/record 兜底恢复
if (!authData && 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",
});
}
if (!authData) {
const msg = loginError || "登录失败";
return NextResponse.json( return NextResponse.json(
{ ok: false, vip: false, error: "PocketBase 管理员认证失败" }, { ok: false, vip: false, error: msg },
{ status: 500 } { status: msg.includes("密码") || msg.includes("邮箱") ? 401 : 503 }
);
}
const adminToken = await getAdminToken().catch((error) => {
console.error("[check-by-email] getAdminToken failed:", error);
return null;
});
if (!adminToken) {
// 管理员 token 获取失败时,降级到 payjsapi 结果,避免直接 500
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 payjsResult: PayjsVipEmailResult | null = null;
let recovery = await resolveRecovery( let recovery = await resolveRecovery(
adminToken, adminToken,
email, email,
@@ -267,12 +334,14 @@ export async function POST(request: NextRequest) {
); );
if (!recovery.vip) { if (!recovery.vip) {
payjsResult = await checkVipByEmailFromPayjsapi( if (!payjsResult) {
request, payjsResult = await checkVipByEmailFromPayjsapi(
email, request,
password, email,
requestedAnonymousUserId || undefined password,
); requestedAnonymousUserId || undefined
);
}
recovery = await resolveRecovery( recovery = await resolveRecovery(
adminToken, adminToken,
email, email,
@@ -291,18 +360,25 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
ok: false, ok: false,
vip: false, vip: false,
error: error: payjsResult?.error ||
"未找到关联的会员记录,请确认已支付并绑定邮箱;如为历史匿名订单,可填写支付时的 user_id 再登录", "未找到关联的会员记录,请确认已支付并绑定邮箱;如为历史匿名订单,可填写支付时的 user_id 再登录",
}); });
} }
const fallbackAnonymousUserId = isAnonymousUserId(requestedAnonymousUserId) let fallbackAnonymousUserId: string | undefined;
? requestedAnonymousUserId if (
: (await findAnonymousUserIdForUser(adminToken, authData.record.id)) || isAnonymousUserId(requestedAnonymousUserId) &&
(await hasVipByUserId(adminToken, requestedAnonymousUserId))
) {
fallbackAnonymousUserId = requestedAnonymousUserId;
} else {
fallbackAnonymousUserId =
(await findAnonymousUserIdForUser(adminToken, authData.record.id)) ||
(typeof payjsResult?.anonymousUserId === "string" && (typeof payjsResult?.anonymousUserId === "string" &&
isAnonymousUserId(payjsResult.anonymousUserId.trim()) isAnonymousUserId(payjsResult.anonymousUserId.trim())
? payjsResult.anonymousUserId.trim() ? payjsResult.anonymousUserId.trim()
: undefined); : undefined);
}
const anonymousUserId = const anonymousUserId =
recovery.anonymousUserId || fallbackAnonymousUserId; recovery.anonymousUserId || fallbackAnonymousUserId;

View File

@@ -15,6 +15,29 @@ type PayjsVipResult = {
email?: string; email?: string;
}; };
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 { function resolvePayApiUrl(request: NextRequest): string {
const payConfig = getPaymentConfig(); const payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
@@ -35,10 +58,11 @@ async function checkVipFromPayjsapi(
)}`; )}`;
console.log("[check-by-user] payjsapi request user_id=%s url=%s", userId, url); console.log("[check-by-user] payjsapi request user_id=%s url=%s", userId, url);
const checkTimeout = Number(process.env.PAYJSAPI_CHECK_TIMEOUT) || 15000; const checkTimeout = Number(process.env.PAYJSAPI_CHECK_TIMEOUT) || 15000;
const res = await fetch( const retries = Math.max(0, Number(process.env.PAYJSAPI_CHECK_RETRIES) || 1);
url, const res = await fetchPayjsVipWithRetry(url, checkTimeout, retries);
{ cache: "no-store", signal: AbortSignal.timeout(checkTimeout) } if (!res) {
); return null;
}
if (!res.ok) { if (!res.ok) {
console.log( console.log(
"[check-by-user] payjsapi non-ok user_id=%s status=%s", "[check-by-user] payjsapi non-ok user_id=%s status=%s",
@@ -109,10 +133,18 @@ export async function POST(request: NextRequest) {
}); });
} }
const adminToken = await getAdminToken(); const adminToken = await getAdminToken().catch((error) => {
console.error("[check-by-user] getAdminToken failed:", error);
return null;
});
if (!adminToken) { if (!adminToken) {
console.error("[check-by-user] missing PocketBase admin token"); console.error("[check-by-user] missing PocketBase admin token, fallback to payjs result");
return NextResponse.json({ ok: false, vip: false }, { status: 500 }); return NextResponse.json({
ok: true,
vip,
alreadyLinked: !!email,
email: email ?? undefined,
});
} }
if (!vip) { if (!vip) {
@@ -135,11 +167,8 @@ export async function POST(request: NextRequest) {
); );
} }
if (vip && !email && isAnonymousUserId(userId)) { if (isAnonymousUserId(userId)) {
const linkRecord = await findVipEmailLinkByAnonymousUserId( const linkRecord = await findVipEmailLinkByAnonymousUserId(adminToken, userId);
adminToken,
userId
);
const linkEmail = const linkEmail =
typeof linkRecord?.email === "string" typeof linkRecord?.email === "string"
? linkRecord.email.trim().toLowerCase() ? linkRecord.email.trim().toLowerCase()
@@ -147,19 +176,34 @@ export async function POST(request: NextRequest) {
const linkUserId = const linkUserId =
typeof linkRecord?.user_id === "string" ? linkRecord.user_id.trim() : ""; typeof linkRecord?.user_id === "string" ? linkRecord.user_id.trim() : "";
if (linkEmail) { // 匿名 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; email = linkEmail;
if (linkUserId && !isAnonymousUserId(linkUserId)) { if (linkUserId && !isAnonymousUserId(linkUserId)) {
linkedUserId = linkUserId; linkedUserId = linkUserId;
} }
vipSource = "vip_email_link"; if (!vipSource || vipSource === "none") {
vipSource = "vip_email_link";
}
console.log( console.log(
"[check-by-user] linked by vip_email_link user_id=%s linkedUserId=%s email=%s", "[check-by-user] linked by vip_email_link user_id=%s linkedUserId=%s email=%s",
userId, userId,
linkedUserId || "(none)", linkedUserId || "(none)",
email email
); );
} else { } else if (vip) {
const linkedMember = await findLinkedMemberFromOrder(adminToken, userId); const linkedMember = await findLinkedMemberFromOrder(adminToken, userId);
if (linkedMember) { if (linkedMember) {
email = linkedMember.email; email = linkedMember.email;
@@ -199,6 +243,7 @@ export async function POST(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error("[check-by-user] failed:", error); console.error("[check-by-user] failed:", error);
return NextResponse.json({ ok: false, vip: false }, { status: 500 }); // 接口容错:即使 PB 回退失败,也不抛 500 给前端
return NextResponse.json({ ok: true, vip: false, error: "check_failed_degraded" });
} }
} }

View File

@@ -28,6 +28,15 @@ export function LoginForm({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const getCandidateUserId = (): string => {
if (typeof window === "undefined") return "";
const fromMemos = localStorage.getItem("memosAccount")?.trim() || "";
if (/^user\d+$/.test(fromMemos)) return fromMemos;
const fromUserId = localStorage.getItem("userId")?.trim() || "";
if (/^user\d+$/.test(fromUserId)) return fromUserId;
return "";
};
useEffect(() => { useEffect(() => {
if (autoFocus) inputRef.current?.focus(); if (autoFocus) inputRef.current?.focus();
}, [autoFocus, mode]); }, [autoFocus, mode]);
@@ -71,7 +80,12 @@ export function LoginForm({
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const result = await onVerifyByEmail(trimmedEmail, trimmedPassword); const candidateUserId = getCandidateUserId();
const result = await onVerifyByEmail(
trimmedEmail,
trimmedPassword,
candidateUserId || undefined
);
const ok = typeof result === "object" ? result.ok : result; const ok = typeof result === "object" ? result.ok : result;
const errMsg = typeof result === "object" ? result.error : undefined; const errMsg = typeof result === "object" ? result.error : undefined;
if (ok) onSuccess(); if (ok) onSuccess();