diff --git a/app/api/auth/email/route.ts b/app/api/auth/email/route.ts new file mode 100644 index 0000000..73b8110 --- /dev/null +++ b/app/api/auth/email/route.ts @@ -0,0 +1,19 @@ +/** + * 从 Cookie 读取邮箱(不调用 PB),用于 emailLinked=1 但 userName 被覆盖的场景 + */ +import { NextRequest, NextResponse } from "next/server"; +import { COOKIE_NAME } from "@/app/lib/auth-cookie"; + +export async function GET(request: NextRequest) { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) { + return NextResponse.json({ ok: true, email: null }); + } + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + const email = payload?.email ?? null; + return NextResponse.json({ ok: true, email: typeof email === "string" ? email : null }); + } catch { + return NextResponse.json({ ok: true, email: null }); + } +} diff --git a/app/api/auth/link-email/route.ts b/app/api/auth/link-email/route.ts index af70e2c..e80b9eb 100644 --- a/app/api/auth/link-email/route.ts +++ b/app/api/auth/link-email/route.ts @@ -1,148 +1,138 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { + ensureSiteVipForUser, + getAdminToken, + isAnonymousUserId, + upsertVipEmailLink, +} from "@/app/api/vip/_helpers"; -async function getAdminToken(): Promise { - const email = process.env.POCKETBASE_EMAIL; - const password = process.env.POCKETBASE_PASSWORD; - if (!email || !password) return null; +const DEFAULT_PASSWORD = "12345678"; + +type PBAuthRecord = { + id: string; + email?: string; +}; + +async function registerOrLoginUser( + email: string, + password: string +): Promise<{ token: string; record: PBAuthRecord } | null> { const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { + + await fetch(`${pb.url}/api/collections/users/records`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!res.ok) return null; - const data = await res.json(); - return data?.token ?? null; -} + body: JSON.stringify({ + email, + password, + passwordConfirm: password, + }), + cache: "no-store", + }).catch(() => null); -/** 将匿名 user_id 的支付记录关联到新创建的 PB 用户 */ -async function linkPaymentsToUser( - adminToken: string, - fromUserId: string, - toUserId: string -): Promise { - const pb = getPocketBaseConfig(); - const filter = `user_id = "${fromUserId}"`; - const res = await fetch( - `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(filter)}&perPage=500`, - { headers: { Authorization: `Bearer ${adminToken}` } } + const loginRes = 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", + } ); - if (!res.ok) return; - const data = await res.json(); - const items = data?.items ?? []; - for (const item of items) { - await fetch(`${pb.url}/api/collections/payments/records/${item.id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ user_id: toUserId }), - }); - } -} -async function linkSiteVipToUser( - adminToken: string, - fromUserId: string, - toUserId: string -): Promise { - const pb = getPocketBaseConfig(); - const filter = `user_id = "${fromUserId}" && site_id = "${SITE_ID}"`; - const res = await fetch( - `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=500`, - { headers: { Authorization: `Bearer ${adminToken}` } } - ); - if (!res.ok) return; - const data = await res.json(); - const items = data?.items ?? []; - for (const item of items) { - await fetch(`${pb.url}/api/collections/site_vip/records/${item.id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ user_id: toUserId }), - }); + if (!loginRes.ok) { + const error = await loginRes.json().catch(() => ({})); + throw new Error(error?.message || "邮箱或密码错误"); } + + const loginData = await loginRes.json().catch(() => ({})); + if (!loginData?.token || !loginData?.record?.id) { + throw new Error("邮箱绑定失败"); + } + + return { + token: loginData.token, + record: loginData.record, + }; } export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})); - const email = String(body?.email ?? "").trim(); - const password = String(body?.password ?? "").trim(); + const email = String(body?.email ?? "").trim().toLowerCase(); + const password = String(body?.password ?? "").trim() || DEFAULT_PASSWORD; const anonymousUserId = String(body?.anonymousUserId ?? "").trim(); - if (!email || !password) { - return NextResponse.json({ ok: false, error: "请填写邮箱和密码" }, { status: 400 }); - } - if (!anonymousUserId || !anonymousUserId.startsWith("user")) { - return NextResponse.json({ ok: false, error: "无效的匿名用户ID" }, { status: 400 }); - } - if (password.length < 8) { - return NextResponse.json({ ok: false, error: "密码至少 8 位" }, { status: 400 }); - } + console.log( + "[link-email] request email=%s anonymousUserId=%s", + email || "(empty)", + anonymousUserId || "(empty)" + ); - const pb = getPocketBaseConfig(); - - // 1. 尝试注册,若已存在则登录 - let token: string; - let record: { id: string; email?: string }; - try { - const registerRes = await fetch(`${pb.url}/api/collections/users/records`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, passwordConfirm: password }), - }); - if (registerRes.ok) { - const regData = await registerRes.json(); - const loginRes = await fetch(`${pb.url}/api/collections/users/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!loginRes.ok) throw new Error("登录失败"); - const loginData = await loginRes.json(); - token = loginData.token; - record = loginData.record; - } else { - const loginRes = await fetch(`${pb.url}/api/collections/users/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!loginRes.ok) { - const err = await loginRes.json().catch(() => ({})); - return NextResponse.json( - { ok: false, error: err?.message || "邮箱或密码错误" }, - { status: 401 } - ); - } - const loginData = await loginRes.json(); - token = loginData.token; - record = loginData.record; - } - } catch (e) { + if (!email) { return NextResponse.json( - { ok: false, error: e instanceof Error ? e.message : "注册/登录失败" }, - { status: 500 } + { ok: false, error: "请填写邮箱" }, + { status: 400 } ); } - const newUserId = record.id; - - // 2. 用管理员 token 将 payments、site_vip 的 user_id 从匿名改为新用户 - const adminToken = await getAdminToken(); - if (adminToken) { - await linkPaymentsToUser(adminToken, anonymousUserId, newUserId); - await linkSiteVipToUser(adminToken, anonymousUserId, newUserId); + if (!isAnonymousUserId(anonymousUserId)) { + return NextResponse.json( + { ok: false, error: "无效的匿名 user_id" }, + { status: 400 } + ); } - return NextResponse.json({ - ok: true, - token, - record: { id: record.id, email: record.email }, - }); + try { + const authData = await registerOrLoginUser(email, password); + if (!authData) { + return NextResponse.json( + { ok: false, error: "邮箱绑定失败" }, + { status: 500 } + ); + } + + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json( + { ok: false, error: "PocketBase 管理员认证失败" }, + { status: 500 } + ); + } + + await ensureSiteVipForUser(adminToken, anonymousUserId, authData.record.id); + await upsertVipEmailLink( + adminToken, + email, + authData.record.id, + anonymousUserId + ); + + console.log( + "[link-email] success email=%s pbUserId=%s anonymousUserId=%s", + email, + authData.record.id, + anonymousUserId + ); + + return NextResponse.json({ + ok: true, + token: authData.token, + record: { + id: authData.record.id, + email: authData.record.email ?? email, + }, + }); + } catch (error) { + console.error("[link-email] failed:", error); + const message = error instanceof Error ? error.message : "邮箱绑定失败"; + const status = message.includes("密码") || message.includes("邮箱") ? 401 : 500; + return NextResponse.json( + { + ok: false, + error: message, + }, + { status } + ); + } } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index ededd1c..ae739ba 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,8 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; +const ANONYMOUS_USER_COOKIE_NAME = "nomadvip_anon_uid"; +const PAY_ORDER_COOKIE_NAME = "nomadvip_pay_order"; + export async function POST(request: NextRequest) { const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record); + const options = getCookieOptions(true, getHostFromRequest(request)) as Record< + string, + string | number | boolean + >; + res.cookies.set(COOKIE_NAME, "", options); + res.cookies.set(ANONYMOUS_USER_COOKIE_NAME, "", { + ...options, + httpOnly: false, + }); + res.cookies.set(PAY_ORDER_COOKIE_NAME, "", { + ...options, + httpOnly: false, + }); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 1e88800..0f513cd 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,48 +1,98 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; -import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; +import { + COOKIE_NAME, + getCookieOptions, + getHostFromRequest, +} from "@/app/lib/auth-cookie"; + +function normalizeAnonymousUserId(value: unknown): string | undefined { + return typeof value === "string" && /^user\d+$/.test(value) + ? value + : undefined; +} export async function GET(request: NextRequest) { const cookie = request.cookies.get(COOKIE_NAME)?.value; if (!cookie) { return NextResponse.json({ ok: true, user: null }); } + try { const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); - const { token, userId, email } = payload; - if (!token || !userId) return NextResponse.json({ ok: true, user: null }); + const { token, userId, email, anonymousUserId } = payload; + const normalizedAnonymousUserId = normalizeAnonymousUserId(anonymousUserId); + + if (!token || !userId) { + return NextResponse.json({ ok: true, user: null }); + } const pb = getPocketBaseConfig(); const res = await fetch(`${pb.url}/api/collections/users/auth-refresh`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) { - const r = NextResponse.json({ ok: true, user: null }); - r.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record); - return r; + const response = NextResponse.json({ ok: true, user: null }); + response.cookies.set( + COOKIE_NAME, + "", + getCookieOptions(true, getHostFromRequest(request)) as Record< + string, + string | number | boolean + > + ); + return response; } + const data = await res.json(); const newToken = data.token; const newRecord = data.record; + if (newToken && newRecord?.id) { - const newPayload = JSON.stringify({ + const nextPayload: Record = { token: newToken, userId: newRecord.id, email: newRecord.email ?? email, - }); - const encoded = Buffer.from(newPayload, "utf-8").toString("base64url"); - const r = NextResponse.json({ + }; + + if (normalizedAnonymousUserId) { + nextPayload.anonymousUserId = normalizedAnonymousUserId; + } + + const encoded = Buffer.from(JSON.stringify(nextPayload), "utf-8").toString( + "base64url" + ); + + const response = NextResponse.json({ ok: true, - user: { id: newRecord.id, email: newRecord.email ?? email }, + user: { + id: newRecord.id, + email: newRecord.email ?? email, + anonymousUserId: normalizedAnonymousUserId, + }, token: newToken, }); - r.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); - return r; + + response.cookies.set( + COOKIE_NAME, + encoded, + getCookieOptions(false, getHostFromRequest(request)) as Record< + string, + string | number | boolean + > + ); + return response; } + return NextResponse.json({ ok: true, - user: { id: data.record?.id ?? userId, email: data.record?.email ?? email }, + user: { + id: data.record?.id ?? userId, + email: data.record?.email ?? email, + anonymousUserId: normalizedAnonymousUserId, + }, token: data.token, }); } catch { diff --git a/app/api/auth/store-email-link/route.ts b/app/api/auth/store-email-link/route.ts new file mode 100644 index 0000000..28e94b8 --- /dev/null +++ b/app/api/auth/store-email-link/route.ts @@ -0,0 +1,125 @@ +/** + * 支付成功后存储 email -> user_id 映射,供「已有账号登录」按邮箱查库 + * 同时创建 PB 用户(邮箱+12345678),以便后续用邮箱+密码登录 + * 需 PocketBase 存在 vip_email_link 集合(email, user_id) + */ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; + +const VIP_EMAIL_LINK_COLLECTION = "vip_email_link"; +const DEFAULT_PASSWORD = "12345678"; + +async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL; + const password = process.env.POCKETBASE_PASSWORD; + if (!email || !password) return null; + const pb = getPocketBaseConfig(); + const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (!res.ok) return null; + const data = await res.json(); + return data?.token ?? null; +} + +/** 若 PB 无该邮箱用户则创建(密码 12345678),供后续邮箱登录 */ +async function ensurePbUserExists(pbUrl: string, email: string): Promise { + const registerRes = await fetch(`${pbUrl}/api/collections/users/records`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password: DEFAULT_PASSWORD, + passwordConfirm: DEFAULT_PASSWORD, + }), + }); + if (registerRes.ok) { + console.log("[store-email-link] 已创建 PB 用户 email=%s", email); + return; + } + const err = await registerRes.json().catch(() => ({})); + const alreadyExists = + err?.data?.email?.message?.includes("unique") || + err?.message?.toLowerCase?.().includes("already") || + err?.message?.toLowerCase?.().includes("unique"); + if (alreadyExists) { + console.log("[store-email-link] PB 用户已存在 email=%s", email); + return; + } + console.warn("[store-email-link] 创建 PB 用户失败 email=%s status=%s", email, registerRes.status, err); +} + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const email = String(body?.email ?? "").trim().toLowerCase(); + const userId = String(body?.user_id ?? body?.userId ?? "").trim(); + + console.log("[store-email-link] 请求 email=%s user_id=%s", email || "(空)", userId || "(空)"); + + if (!email || !userId) { + return NextResponse.json({ ok: false, error: "请填写邮箱" }, { status: 400 }); + } + + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json({ ok: false, error: "服务未配置" }, { status: 500 }); + } + + const pb = getPocketBaseConfig(); + try { + await ensurePbUserExists(pb.url, email); + } catch (e) { + console.warn("[store-email-link] ensurePbUserExists 异常:", e); + } + + const safeEmail = email.replace(/"/g, '\\"'); + const filter = `email = "${safeEmail}"`; + try { + const listRes = await fetch( + `${pb.url}/api/collections/${VIP_EMAIL_LINK_COLLECTION}/records?filter=${encodeURIComponent(filter)}&perPage=1`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + if (!listRes.ok) { + return NextResponse.json({ ok: false, error: "vip_email_link 集合不存在,请在 PocketBase 中创建" }, { status: 500 }); + } + const listData = await listRes.json().catch(() => ({})); + const items = listData?.items ?? []; + const recordBody: Record = { email, user_id: userId }; + if (/^user\d+$/.test(userId)) { + recordBody.anonymous_user_id = userId; + } + if (items.length > 0) { + await fetch(`${pb.url}/api/collections/${VIP_EMAIL_LINK_COLLECTION}/records/${items[0].id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify(recordBody), + }); + } else { + const createRes = await fetch(`${pb.url}/api/collections/${VIP_EMAIL_LINK_COLLECTION}/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify(recordBody), + }); + if (!createRes.ok) { + const err = await createRes.json().catch(() => ({})); + return NextResponse.json({ ok: false, error: err?.message || "写入失败" }, { status: 500 }); + } + } + console.log("[store-email-link] 成功 email=%s user_id=%s", email, userId); + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("[store-email-link] error:", e); + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : "操作失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts index 4c1dac3..6df84ef 100644 --- a/app/api/auth/sync-session/route.ts +++ b/app/api/auth/sync-session/route.ts @@ -1,22 +1,46 @@ import { NextRequest, NextResponse } from "next/server"; -import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; +import { + COOKIE_NAME, + getCookieOptions, + getHostFromRequest, +} from "@/app/lib/auth-cookie"; export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})); const token = body?.token; const record = body?.record; + const anonymousUserId = + typeof body?.anonymousUserId === "string" ? body.anonymousUserId.trim() : ""; + if (!token || !record?.id) { - return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 }); + return NextResponse.json( + { ok: false, error: "缺少 token 或 record" }, + { status: 400 } + ); } - const payload = JSON.stringify({ + const payload: Record = { token, userId: record.id, email: record.email ?? "", - }); - const encoded = Buffer.from(payload, "utf-8").toString("base64url"); + }; + + if (/^user\d+$/.test(anonymousUserId)) { + payload.anonymousUserId = anonymousUserId; + } + + const encoded = Buffer.from(JSON.stringify(payload), "utf-8").toString( + "base64url" + ); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); + res.cookies.set( + COOKIE_NAME, + encoded, + getCookieOptions(false, getHostFromRequest(request)) as Record< + string, + string | number | boolean + > + ); return res; } diff --git a/app/api/pay/confirm/route.ts b/app/api/pay/confirm/route.ts new file mode 100644 index 0000000..073c306 --- /dev/null +++ b/app/api/pay/confirm/route.ts @@ -0,0 +1,253 @@ +/** + * 支付确认:payjsapi 未写入时,由 nomadvip 直接写入 PocketBase + 创建 Memos + */ +import { NextRequest, NextResponse } from "next/server"; +import { + getApiUrlFromRequest, + getPocketBaseConfig, + getPaymentConfig, + SITE_ID, +} from "@/config/services.config"; +import { queryXorPayOrderStatus } from "@/app/lib/xorpayStatus"; +import { queryZPayOrderStatus } from "@/app/lib/zpayStatus"; + +const getTotalFee = () => + getPaymentConfig().defaultAmount ?? Number(process.env.PAYMENT_DEFAULT_AMOUNT) ?? 8800; + +async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL; + const password = process.env.POCKETBASE_PASSWORD; + if (!email || !password) return null; + const pb = getPocketBaseConfig(); + const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"]; + for (const path of paths) { + const res = await fetch(`${pb.url}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + }); + if (res.ok) { + const data = await res.json(); + if (data?.token) return data.token; + } + } + return null; +} + +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) || + process.env.PAYMENT_API_URL || + process.env.NEXT_PUBLIC_API_URL || + payConfig.apiUrl + ).replace(/\/$/, ""); +} + +async function verifyOrderPaid( + request: NextRequest, + orderId: string +): Promise { + const apiUrl = resolvePayApiUrl(request); + const url = `${apiUrl}/nomadvip/order_status?order_id=${encodeURIComponent(orderId)}`; + console.log("[pay/confirm] verify order_status url=%s", url); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(url, { + cache: "no-store", + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); + const data = await res.json().catch(() => ({})); + const paid = !!data?.paid; + if (!paid && !res.ok) { + console.warn("[pay/confirm] order_status non-ok:", res.status, url, data); + } + if (paid) { + return true; + } + } catch (e) { + console.error("[pay/confirm] verifyOrderPaid fetch error:", e); + } + + const xorpay = await queryXorPayOrderStatus(orderId, 5000); + console.log( + "[pay/confirm] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s", + orderId, + !!xorpay?.paid, + xorpay?.status || "", + xorpay?.error || "", + xorpay?.url || "" + ); + if (xorpay?.paid) { + return true; + } + + const zpay = await queryZPayOrderStatus(orderId, 5000); + console.log( + "[pay/confirm] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s", + orderId, + !!zpay?.paid, + zpay?.status || "", + zpay?.error || "", + zpay?.url || "" + ); + return !!zpay?.paid; +} + +async function createMemosUser(username: string, password: string): Promise { + const baseUrl = process.env.MEMOS_API_URL || "https://qun.hackrobot.cn"; + const token = process.env.MEMOS_ACCESS_TOKEN; + if (!token) return false; + const url = `${baseUrl.replace(/\/$/, "")}/api/v1/users`; + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + username, + password, + role: "USER", + state: "NORMAL", + }), + }); + if (res.ok) return true; + const err = await res.json().catch(() => ({})); + const alreadyExists = + err?.code === 6 || + err?.code === 13 || + err?.message?.includes("already exists") || + err?.message?.includes("UNIQUE") || + err?.message?.includes("constraint"); + if (alreadyExists) return true; + console.error("Memos create user failed:", res.status, err); + return false; + } catch (e) { + console.error("Memos create user error:", e); + return false; + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const orderId = String(body?.order_id ?? body?.orderId ?? "").trim(); + const userId = String(body?.user_id ?? body?.userId ?? "").trim(); + + console.log("[pay/confirm] 请求 order_id=%s user_id=%s", orderId || "(空)", userId || "(空)"); + + if (!orderId || !userId) { + return NextResponse.json({ ok: false, error: "缺少 order_id 或 user_id" }, { status: 400 }); + } + + const paid = await verifyOrderPaid(request, orderId); + if (!paid) { + console.error("[pay/confirm] 订单未支付 order_id=%s user_id=%s (请检查 payjsapi order_status 接口)", orderId, userId); + return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 }); + } + + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json({ ok: false, error: "PocketBase 未配置" }, { status: 500 }); + } + + const pb = getPocketBaseConfig(); + const totalFee = getTotalFee(); + + const checkExisting = await fetch( + `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + const existingData = await checkExisting.json().catch(() => ({})); + const paymentsExist = (existingData?.items?.length ?? 0) > 0; + + if (!paymentsExist) { + const paymentsRes = await fetch(`${pb.url}/api/collections/payments/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + user_id: userId, + type: "vip", + order_id: orderId, + total_fee: totalFee, + amount: totalFee, + channel: "wxpay", + }), + }); + + if (!paymentsRes.ok) { + const err = await paymentsRes.json().catch(() => ({})); + if (err?.data?.order_id?.message?.includes("unique") || err?.message?.includes("unique")) { + // 已存在(并发),继续 + } else { + const msg = err?.message || err?.data ? JSON.stringify(err) : await paymentsRes.text(); + console.error("[pay/confirm] payments create failed:", paymentsRes.status, msg); + return NextResponse.json( + { ok: false, error: "写入 payments 失败", detail: msg }, + { status: 500 } + ); + } + } + } + + const siteVipCheck = await fetch( + `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(`user_id="${userId}" && site_id="${SITE_ID}"`)}&perPage=1`, + { headers: { Authorization: `Bearer ${adminToken}` } } + ); + const siteVipData = await siteVipCheck.json().catch(() => ({})); + const siteVipItems = siteVipData?.items ?? []; + + if (siteVipItems.length > 0) { + await fetch(`${pb.url}/api/collections/site_vip/records/${siteVipItems[0].id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ order_id: orderId }), + }); + } else { + const siteVipRes = await fetch(`${pb.url}/api/collections/site_vip/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + user_id: userId, + site_id: SITE_ID, + order_id: orderId, + }), + }); + + if (!siteVipRes.ok) { + const err = await siteVipRes.json().catch(() => ({})); + if (err?.data?.order_id?.message?.includes("unique") || err?.message?.includes("unique")) { + // 已存在,继续 + } else { + console.error("site_vip create failed:", await siteVipRes.text()); + } + } + } + + if (userId.startsWith("user") && /^user\d+$/.test(userId)) { + await createMemosUser(userId, "12345678"); + } + + console.log("[pay/confirm] 成功 order_id=%s user_id=%s", orderId, userId); + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("pay/confirm error:", e); + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : "确认失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index c85024b..35c6ebf 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -4,8 +4,11 @@ */ import { NextRequest, NextResponse } from "next/server"; import { getPaymentConfig, getApiUrlFromRequest } from "@/config/services.config"; +import { getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"]; +const ANONYMOUS_USER_COOKIE_NAME = "nomadvip_anon_uid"; +const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; function escapeAttr(s: string): string { return String(s) @@ -47,6 +50,25 @@ function setPayOrderCookie(res: NextResponse, orderId: string) { res.cookies.set("nomadvip_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 }); } +function setAnonymousUserCookie( + res: NextResponse, + request: NextRequest, + userId: string +) { + if (!/^user\d+$/.test(userId)) { + return; + } + const options = { + ...(getCookieOptions(false, getHostFromRequest(request)) as Record< + string, + string | number | boolean + >), + httpOnly: false, + maxAge: ANONYMOUS_USER_COOKIE_MAX_AGE, + }; + res.cookies.set(ANONYMOUS_USER_COOKIE_NAME, userId, options); +} + async function createPayOrder( apiUrl: string, user_id: string, @@ -67,15 +89,18 @@ async function createPayOrder( ...(clientIp && { client_ip: clientIp }), ...(provider && { provider }), }; + const payh5Url = `${apiUrl}/nomadvip/payh5`; + console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30000); - const res = await fetch(`${apiUrl}/nomadvip/payh5`, { + const timeout = setTimeout(() => controller.abort(), 10000); + const res = await fetch(payh5Url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal, }).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); + console.log("[pay] createPayOrder 响应 status=%s ok=%s", res.status, data?.status); if (!res.ok || data?.status !== "ok") { throw new Error(data?.detail || data?.msg || "支付创建失败"); } @@ -105,7 +130,15 @@ export async function POST(request: NextRequest) { const payConfig = getPaymentConfig(); const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); - const apiUrl = (getApiUrlFromRequest(hostHeader) || payConfig.apiUrl).replace(/\/$/, ""); + // 优先用请求 Host(等同 window.location.host)推断同机 payjsapi,访问 192.168.41.222:3002 即用 192.168.41.222:8007 + const apiUrl = ( + (hostHeader ? getApiUrlFromRequest(hostHeader) : null) || + process.env.PAYMENT_API_URL || + process.env.NEXT_PUBLIC_API_URL || + payConfig.apiUrl + ) + .replace(/\/$/, ""); + console.log("[pay] 立即加入 apiUrl=%s host=%s", apiUrl, hostHeader); let user_id = String(body?.user_id || "").trim(); if (user_id.startsWith("{") && user_id.includes("data")) { try { @@ -178,6 +211,7 @@ export async function POST(request: NextRequest) { order_id: orderId, }); if (orderId) setPayOrderCookie(res, orderId); + setAnonymousUserCookie(res, request, user_id); return res; } @@ -193,6 +227,7 @@ export async function POST(request: NextRequest) { headers: { "Content-Type": "text/html; charset=utf-8" }, }); if (orderId) setPayOrderCookie(res, orderId); + setAnonymousUserCookie(res, request, user_id); return res; } @@ -210,7 +245,7 @@ export async function POST(request: NextRequest) { }; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 45000); + const timeout = setTimeout(() => controller.abort(), 20000); const redirectRes = await fetch(`${apiUrl}/nomadvip/payh5/redirect`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -224,7 +259,7 @@ export async function POST(request: NextRequest) { if (location) { const orderId = redirectRes.headers.get("x-pay-order-id")?.trim() || - String(params?.out_trade_no || "").trim(); + String(params?.order_id || params?.out_trade_no || "").trim(); if (preferJson && orderId) { return NextResponse.json({ ok: true, @@ -235,6 +270,7 @@ export async function POST(request: NextRequest) { } const res = NextResponse.redirect(location); if (orderId) setPayOrderCookie(res, orderId); + setAnonymousUserCookie(res, request, user_id); return res; } } @@ -251,10 +287,12 @@ export async function POST(request: NextRequest) { headers: { "Content-Type": "text/html; charset=utf-8" }, }); if (orderId) setPayOrderCookie(res, orderId); + setAnonymousUserCookie(res, request, user_id); return res; } if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) { + const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); const returnUrl = String(resData.return_url || finalReturnUrl).replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c @@ -337,6 +375,7 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;} (function(){ var orderId="${orderId}"; var returnUrl="${returnUrl}"; + var userId="${userId}"; if(!orderId){return;} var waitWrap=document.getElementById("waitWrap"); var successWrap=document.getElementById("successWrap"); @@ -362,26 +401,39 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;} clearInterval(countdownTimer); waitWrap.classList.add("hide"); successWrap.classList.add("show"); - var s=10; + var s=2; var t=setInterval(function(){ s--; - successCountdown.textContent=s+" 秒后返回首页"; + successCountdown.textContent=s>0?s+" 秒后返回首页":"返回首页中…"; if(s<=0){ clearInterval(t); - window.location.href=returnUrl; + try{localStorage.setItem("userId",userId);if(/^user\\d+$/.test(userId))localStorage.setItem("memosAccount",userId);localStorage.setItem("paidType","vip");if(!localStorage.getItem("emailLinked")&&!localStorage.getItem("userEmail"))localStorage.setItem("userName",userId);}catch(e){} + window.location.href="/"; } },1000); } + function doConfirm(attempt){ + if(!userId||attempt>=3){showSuccess();return;} + fetch("/api/pay/confirm",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId,user_id:userId})}) + .then(function(r){return r.json();}) + .then(function(cd){ + if(cd&&cd.ok){showSuccess();} + else{setTimeout(function(){doConfirm(attempt+1);},800);} + }) + .catch(function(){setTimeout(function(){doConfirm(attempt+1);},800);}); + } function check(){ fetch("/api/pay/status?order_id="+encodeURIComponent(orderId)) .then(function(r){return r.json();}) .then(function(d){ - if(d.ok&&d.paid){showSuccess();return;} - setTimeout(check,1000); + if(d.ok&&d.paid){ + doConfirm(0);return; + } + setTimeout(check,500); }) - .catch(function(){setTimeout(check,1000);}); + .catch(function(){setTimeout(check,500);}); } - setTimeout(check,1000); + setTimeout(check,500); })(); `; @@ -390,6 +442,7 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;} headers: { "Content-Type": "text/html; charset=utf-8" }, }); if (orderId) setPayOrderCookie(res, orderId); + setAnonymousUserCookie(res, request, user_id); return res; } @@ -415,11 +468,12 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;} } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); const msg = err.message || ""; + const payjsapiHint = "请确认 payjsapi 已启动 (npm run dev 或 python run.py,端口 8007)"; const hint = msg.includes("fetch") || msg.includes("ECONNREFUSED") - ? "请确认 payjsapi 已启动" + ? payjsapiHint : err.name === "AbortError" - ? "请求超时" + ? `请求超时,${payjsapiHint}` : msg; console.error("Pay API error:", err); const errorMsg = `支付请求失败: ${hint}`; diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts index 78fd67c..2b8a43d 100644 --- a/app/api/pay/status/route.ts +++ b/app/api/pay/status/route.ts @@ -1,24 +1,80 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPaymentConfig } from "@/config/services.config"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; +import { queryXorPayOrderStatus } from "@/app/lib/xorpayStatus"; +import { queryZPayOrderStatus } from "@/app/lib/zpayStatus"; + +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) || + process.env.PAYMENT_API_URL || + process.env.NEXT_PUBLIC_API_URL || + payConfig.apiUrl + ).replace(/\/$/, ""); +} export async function GET(request: NextRequest) { const orderId = request.nextUrl.searchParams.get("order_id"); if (!orderId) { - return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 }); + return NextResponse.json({ ok: false, error: "missing order_id" }, { status: 400 }); } - const payConfig = getPaymentConfig(); - const apiUrl = payConfig.apiUrl.replace(/\/$/, ""); + + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + const apiUrl = resolvePayApiUrl(request); + const url = `${apiUrl}/nomadvip/order_status?order_id=${encodeURIComponent(orderId)}`; + console.log("[pay/status] request order_id=%s host=%s url=%s", orderId, hostHeader, url); + try { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 25000); - // 使用 order_status 通用接口(xorpay 查 PocketBase,zpay 查 ZPAY) - const res = await fetch( - `${apiUrl}/nomadvip/order_status?order_id=${encodeURIComponent(orderId)}`, - { cache: "no-store", signal: controller.signal } - ).finally(() => clearTimeout(timeout)); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(url, { + cache: "no-store", + signal: controller.signal, + }).finally(() => clearTimeout(timeout)); const data = await res.json().catch(() => ({})); - return NextResponse.json({ ok: true, paid: !!data?.paid }); - } catch (e) { - return NextResponse.json({ ok: true, paid: false }); + const paid = !!data?.paid; + console.log( + "[pay/status] result order_id=%s paid=%s status=%s", + orderId, + paid, + res.status + ); + if (paid) { + return NextResponse.json({ ok: true, paid: true }); + } + } catch (error) { + console.log( + "[pay/status] error order_id=%s error=%s", + orderId, + error instanceof Error ? error.message : String(error) + ); } + + const xorpay = await queryXorPayOrderStatus(orderId, 5000); + console.log( + "[pay/status] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s", + orderId, + !!xorpay?.paid, + xorpay?.status || "", + xorpay?.error || "", + xorpay?.url || "" + ); + if (xorpay?.paid) { + return NextResponse.json({ ok: true, paid: true }); + } + + const zpay = await queryZPayOrderStatus(orderId, 5000); + console.log( + "[pay/status] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s", + orderId, + !!zpay?.paid, + zpay?.status || "", + zpay?.error || "", + zpay?.url || "" + ); + if (zpay?.paid) { + return NextResponse.json({ ok: true, paid: true }); + } + return NextResponse.json({ ok: true, paid: false }); } diff --git a/app/api/vip/_helpers.ts b/app/api/vip/_helpers.ts new file mode 100644 index 0000000..a594544 --- /dev/null +++ b/app/api/vip/_helpers.ts @@ -0,0 +1,510 @@ +import { SITE_ID, getPocketBaseConfig } from "@/config/services.config"; + +const VIP_EMAIL_LINK_COLLECTION = "vip_email_link"; + +type PBRecord = Record; + +function getPBBaseUrl(): string { + return getPocketBaseConfig().url.replace(/\/$/, ""); +} + +function escapeFilterValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function uniqueStrings(values: Array): string[] { + return Array.from( + new Set( + values + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + ) + ); +} + +async function listRecords( + collection: string, + filter: string, + adminToken: string, + options: { perPage?: number; sort?: string } = {} +): Promise { + const url = new URL(`${getPBBaseUrl()}/api/collections/${collection}/records`); + url.searchParams.set("filter", filter); + url.searchParams.set("perPage", String(options.perPage ?? 20)); + if (options.sort) { + url.searchParams.set("sort", options.sort); + } + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${adminToken}` }, + cache: "no-store", + }); + if (!res.ok) { + return []; + } + + const data = await res.json().catch(() => ({})); + return Array.isArray(data?.items) ? data.items : []; +} + +async function createRecord( + collection: string, + adminToken: string, + body: Record +): Promise { + await fetch(`${getPBBaseUrl()}/api/collections/${collection}/records`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify(body), + cache: "no-store", + }); +} + +async function patchRecord( + collection: string, + recordId: string, + adminToken: string, + body: Record +): Promise { + await fetch( + `${getPBBaseUrl()}/api/collections/${collection}/records/${recordId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify(body), + cache: "no-store", + } + ); +} + +async function deleteRecord( + collection: string, + recordId: string, + adminToken: string +): Promise { + await fetch( + `${getPBBaseUrl()}/api/collections/${collection}/records/${recordId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${adminToken}` }, + cache: "no-store", + } + ); +} + +async function getRecord( + collection: string, + recordId: string, + adminToken: string +): Promise { + const res = await fetch( + `${getPBBaseUrl()}/api/collections/${collection}/records/${recordId}`, + { + headers: { Authorization: `Bearer ${adminToken}` }, + cache: "no-store", + } + ); + if (!res.ok) { + return null; + } + + return (await res.json().catch(() => null)) as PBRecord | null; +} + +function isVipExpired(expiresAt: unknown): boolean { + if (typeof expiresAt !== "string" || !expiresAt.trim()) { + return false; + } + + const timestamp = Date.parse(expiresAt); + return Number.isFinite(timestamp) ? timestamp < Date.now() : false; +} + +export function isAnonymousUserId( + value: string | null | undefined +): value is string { + return !!value && /^user\d+$/.test(value); +} + +export async function getAdminToken(): Promise { + const email = process.env.POCKETBASE_EMAIL; + const password = process.env.POCKETBASE_PASSWORD; + if (!email || !password) { + return null; + } + + const baseUrl = getPBBaseUrl(); + const paths = [ + "/api/admins/auth-with-password", + "/api/collections/_superusers/auth-with-password", + ]; + + for (const path of paths) { + const res = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password }), + cache: "no-store", + }); + if (!res.ok) { + continue; + } + + const data = await res.json().catch(() => ({})); + if (typeof data?.token === "string" && data.token) { + return data.token; + } + } + + return null; +} + +export async function listSiteVipByUserId( + adminToken: string, + userId: string +): Promise { + const safeUserId = escapeFilterValue(userId); + const safeSiteId = escapeFilterValue(SITE_ID); + return listRecords( + "site_vip", + `user_id = "${safeUserId}" && site_id = "${safeSiteId}"`, + adminToken, + { perPage: 500, sort: "-created" } + ); +} + +export async function listSiteVipByOrderId( + adminToken: string, + orderId: string +): Promise { + const safeOrderId = escapeFilterValue(orderId); + const safeSiteId = escapeFilterValue(SITE_ID); + return listRecords( + "site_vip", + `order_id = "${safeOrderId}" && site_id = "${safeSiteId}"`, + adminToken, + { perPage: 500, sort: "-created" } + ); +} + +async function listPaymentsByOrderId( + adminToken: string, + orderId: string +): Promise { + const safeOrderId = escapeFilterValue(orderId); + return listRecords( + "payments", + `order_id = "${safeOrderId}" && type = "vip"`, + adminToken, + { perPage: 20, sort: "-created" } + ); +} + +export async function getActiveSiteVipRecord( + adminToken: string, + userId: string +): Promise { + const records = await listSiteVipByUserId(adminToken, userId); + for (const record of records) { + if (!isVipExpired(record.expires_at)) { + return record; + } + } + return null; +} + +export async function checkSiteVipByUserId( + adminToken: string, + userId: string +): Promise { + return !!(await getActiveSiteVipRecord(adminToken, userId)); +} + +export async function getLatestVipPayment( + adminToken: string, + userId: string +): Promise { + const safeUserId = escapeFilterValue(userId); + const records = await listRecords( + "payments", + `user_id = "${safeUserId}" && type = "vip"`, + adminToken, + { perPage: 1, sort: "-created" } + ); + return records[0] ?? null; +} + +export async function checkPaymentsByUserId( + adminToken: string, + userId: string +): Promise { + return !!(await getLatestVipPayment(adminToken, userId)); +} + +export async function hasVipByUserId( + adminToken: string, + userId: string +): Promise { + if (!userId) { + return false; + } + if (await checkSiteVipByUserId(adminToken, userId)) { + return true; + } + return checkPaymentsByUserId(adminToken, userId); +} + +export async function ensureSiteVipForUser( + adminToken: string, + fromUserId: string, + toUserId: string +): Promise { + if (!fromUserId || !toUserId || fromUserId === toUserId) { + return; + } + + const sourceRecords = await listSiteVipByUserId(adminToken, fromUserId); + const targetRecords = await listSiteVipByUserId(adminToken, toUserId); + const latestPayment = await getLatestVipPayment(adminToken, fromUserId); + const orderId = + typeof latestPayment?.order_id === "string" ? latestPayment.order_id.trim() : ""; + const orderRecords = orderId + ? await listSiteVipByOrderId(adminToken, orderId) + : []; + + const canonicalRecord = + targetRecords[0] ?? sourceRecords[0] ?? orderRecords[0] ?? null; + const canonicalRecordId = + typeof canonicalRecord?.id === "string" ? canonicalRecord.id : ""; + + if (canonicalRecordId) { + const nextOrderId = + orderId || + (typeof canonicalRecord?.order_id === "string" + ? canonicalRecord.order_id.trim() + : ""); + + const body: Record = { user_id: toUserId, site_id: SITE_ID }; + if (nextOrderId) { + body.order_id = nextOrderId; + } + + await patchRecord("site_vip", canonicalRecordId, adminToken, body); + + const duplicateIds = new Set(); + for (const record of [...targetRecords, ...sourceRecords, ...orderRecords]) { + const recordId = typeof record.id === "string" ? record.id : ""; + if (recordId && recordId !== canonicalRecordId) { + duplicateIds.add(recordId); + } + } + + for (const recordId of duplicateIds) { + await deleteRecord("site_vip", recordId, adminToken); + } + return; + } + + if (!orderId) { + return; + } + + await createRecord("site_vip", adminToken, { + user_id: toUserId, + site_id: SITE_ID, + order_id: orderId, + }); +} + +export async function upsertVipEmailLink( + adminToken: string, + email: string, + userId: string, + anonymousUserId?: string +): Promise { + if (!email || !userId) { + return; + } + + const normalizedEmail = email.trim().toLowerCase(); + const safeEmail = escapeFilterValue(normalizedEmail); + const records = await listRecords( + VIP_EMAIL_LINK_COLLECTION, + `email = "${safeEmail}"`, + adminToken, + { perPage: 1 } + ); + + const body: Record = { + email: normalizedEmail, + user_id: userId, + }; + if (anonymousUserId) { + body.anonymous_user_id = anonymousUserId; + } + + const recordId = typeof records[0]?.id === "string" ? records[0].id : ""; + if (recordId) { + await patchRecord(VIP_EMAIL_LINK_COLLECTION, recordId, adminToken, body); + return; + } + + await createRecord(VIP_EMAIL_LINK_COLLECTION, adminToken, body); +} + +export async function findVipEmailLinkByEmail( + adminToken: string, + email: string +): Promise { + const candidates = uniqueStrings([email, email.toLowerCase()]); + for (const candidate of candidates) { + const safeEmail = escapeFilterValue(candidate); + const records = await listRecords( + VIP_EMAIL_LINK_COLLECTION, + `email = "${safeEmail}"`, + adminToken, + { perPage: 1 } + ); + if (records[0]) { + return records[0]; + } + } + return null; +} + +export async function findVipEmailLinkByUserId( + adminToken: string, + userId: string +): Promise { + const safeUserId = escapeFilterValue(userId); + const records = await listRecords( + VIP_EMAIL_LINK_COLLECTION, + `user_id = "${safeUserId}"`, + adminToken, + { perPage: 1 } + ); + return records[0] ?? null; +} + +export async function findVipEmailLinkByAnonymousUserId( + adminToken: string, + anonymousUserId: string +): Promise { + const filters = uniqueStrings([ + `anonymous_user_id = "${escapeFilterValue(anonymousUserId)}"`, + isAnonymousUserId(anonymousUserId) + ? `user_id = "${escapeFilterValue(anonymousUserId)}"` + : null, + ]); + + for (const filter of filters) { + const records = await listRecords( + VIP_EMAIL_LINK_COLLECTION, + filter, + adminToken, + { perPage: 1 } + ); + if (records[0]) { + return records[0]; + } + } + + return null; +} + +export async function getUserEmailById( + adminToken: string, + userId: string +): Promise { + const record = await getRecord("users", userId, adminToken); + const email = + typeof record?.email === "string" ? record.email.trim().toLowerCase() : ""; + return email || null; +} + +export async function findLinkedMemberFromOrder( + adminToken: string, + anonymousUserId: string +): Promise<{ userId: string; email: string } | null> { + const safeUserId = escapeFilterValue(anonymousUserId); + const payments = await listRecords( + "payments", + `user_id = "${safeUserId}" && type = "vip"`, + adminToken, + { perPage: 20, sort: "-created" } + ); + + const seenOrders = new Set(); + for (const payment of payments) { + const orderId = + typeof payment.order_id === "string" ? payment.order_id.trim() : ""; + if (!orderId || seenOrders.has(orderId)) { + continue; + } + seenOrders.add(orderId); + + const siteVipRecords = await listSiteVipByOrderId(adminToken, orderId); + for (const record of siteVipRecords) { + const linkedUserId = + typeof record.user_id === "string" ? record.user_id.trim() : ""; + if ( + !linkedUserId || + linkedUserId === anonymousUserId || + isAnonymousUserId(linkedUserId) + ) { + continue; + } + + const email = await getUserEmailById(adminToken, linkedUserId); + if (email) { + return { userId: linkedUserId, email }; + } + } + } + + return null; +} + +export async function findAnonymousUserIdForUser( + adminToken: string, + userId: string +): Promise { + if (isAnonymousUserId(userId)) { + return userId; + } + + const linkRecord = await findVipEmailLinkByUserId(adminToken, userId); + const linkedAnonymousUserId = + typeof linkRecord?.anonymous_user_id === "string" + ? linkRecord.anonymous_user_id.trim() + : ""; + if (isAnonymousUserId(linkedAnonymousUserId)) { + return linkedAnonymousUserId; + } + + const siteVipRecords = await listSiteVipByUserId(adminToken, userId); + for (const record of siteVipRecords) { + const orderId = + typeof record.order_id === "string" ? record.order_id.trim() : ""; + if (!orderId) { + continue; + } + + const payments = await listPaymentsByOrderId(adminToken, orderId); + for (const payment of payments) { + const anonymousUserId = + typeof payment.user_id === "string" ? payment.user_id.trim() : ""; + if (isAnonymousUserId(anonymousUserId)) { + return anonymousUserId; + } + } + } + + return null; +} diff --git a/app/api/vip/check-by-email/route.ts b/app/api/vip/check-by-email/route.ts new file mode 100644 index 0000000..9c59a29 --- /dev/null +++ b/app/api/vip/check-by-email/route.ts @@ -0,0 +1,329 @@ +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; + effectiveUserId?: string; + anonymousUserId?: string; +}; + +type RecoveryResult = { + vip: boolean; + matchedUserId?: string; + anonymousUserId?: string; + source?: string; +}; + +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) || + process.env.PAYMENT_API_URL || + process.env.NEXT_PUBLIC_API_URL || + payConfig.apiUrl + ).replace(/\/$/, ""); +} + +async function checkVipByEmailFromPayjsapi( + request: NextRequest, + email: string, + password: string, + userId?: string +): Promise { + try { + const apiUrl = resolvePayApiUrl(request); + 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 } : {}), + }), + cache: "no-store", + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) { + return null; + } + + const data = await res.json().catch(() => ({})); + if (!data?.vip) { + return null; + } + + return { + vip: true, + effectiveUserId: + typeof data?.effectiveUserId === "string" + ? data.effectiveUserId.trim() + : undefined, + anonymousUserId: + typeof data?.anonymousUserId === "string" + ? data.anonymousUserId.trim() + : undefined, + }; + } catch (error) { + console.log("[check-by-email] payjsapi request failed:", error); + return null; + } +} + +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", + }); + + 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) && + (await hasVipByUserId(adminToken, requestedAnonymousUserId)) + ) { + return { + vip: true, + matchedUserId: requestedAnonymousUserId, + anonymousUserId: requestedAnonymousUserId, + source: "request_user_id", + }; + } + + const linkRecord = await findVipEmailLinkByEmail(adminToken, email); + const linkedAnonymousUserId = + typeof linkRecord?.anonymous_user_id === "string" + ? linkRecord.anonymous_user_id.trim() + : (await findAnonymousUserIdForUser(adminToken, pbUserId)) ?? ""; + const linkedIds = Array.from( + new Set( + [ + linkedAnonymousUserId, + typeof linkRecord?.user_id === "string" ? linkRecord.user_id.trim() : "", + pbUserId, + ].filter(Boolean) + ) + ); + + for (const linkedId of linkedIds) { + if (!(await hasVipByUserId(adminToken, linkedId))) { + continue; + } + + return { + vip: true, + matchedUserId: linkedId, + anonymousUserId: isAnonymousUserId(linkedAnonymousUserId) + ? linkedAnonymousUserId + : isAnonymousUserId(linkedId) + ? linkedId + : undefined, + source: "vip_email_link", + }; + } + + if (await hasVipByUserId(adminToken, pbUserId)) { + 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) + ) + ); + + for (const candidate of payjsCandidates) { + if (!(await hasVipByUserId(adminToken, candidate))) { + continue; + } + + return { + vip: true, + matchedUserId: candidate, + anonymousUserId: isAnonymousUserId(candidate) + ? 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(); + + 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 authData = await loginPocketBaseUser(email, password); + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json( + { ok: false, vip: false, error: "PocketBase 管理员认证失败" }, + { status: 500 } + ); + } + + let payjsResult: PayjsVipEmailResult | null = null; + let recovery = await resolveRecovery( + adminToken, + email, + authData.record.id, + requestedAnonymousUserId || undefined, + payjsResult + ); + + if (!recovery.vip) { + payjsResult = await checkVipByEmailFromPayjsapi( + request, + email, + password, + requestedAnonymousUserId || 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: + "未找到关联的会员记录,请确认已支付并绑定邮箱;如为历史匿名订单,可填写支付时的 user_id 再登录", + }); + } + + const fallbackAnonymousUserId = isAnonymousUserId(requestedAnonymousUserId) + ? requestedAnonymousUserId + : (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 + ); + + 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 } + ); + } +} diff --git a/app/api/vip/check-by-user/route.ts b/app/api/vip/check-by-user/route.ts new file mode 100644 index 0000000..dbf336d --- /dev/null +++ b/app/api/vip/check-by-user/route.ts @@ -0,0 +1,205 @@ +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; +}; + +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) || + process.env.PAYMENT_API_URL || + process.env.NEXT_PUBLIC_API_URL || + payConfig.apiUrl + ).replace(/\/$/, ""); +} + +async function checkVipFromPayjsapi( + request: NextRequest, + userId: string +): Promise { + 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 res = await fetch( + url, + { cache: "no-store", signal: AbortSignal.timeout(5000) } + ); + 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; + } +} + +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 payjsResult = await checkVipFromPayjsapi(request, userId); + let vip = !!payjsResult?.vip; + let email = payjsResult?.email ?? null; + let linkedUserId: string | undefined; + let vipFromSite = false; + let vipFromPayments = false; + let vipSource = vip ? "payjsapi" : "none"; + + try { + if (vip) { + console.log( + "[check-by-user] hit payjsapi user_id=%s alreadyLinked=%s", + userId, + !!email + ); + return NextResponse.json({ + ok: true, + vip: true, + alreadyLinked: !!email, + email: email ?? undefined, + }); + } + + const adminToken = await getAdminToken(); + if (!adminToken) { + console.error("[check-by-user] missing PocketBase admin token"); + return NextResponse.json({ ok: false, vip: false }, { status: 500 }); + } + + 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 + ); + } + + if (vip && !email && 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() : ""; + + if (linkEmail) { + email = linkEmail; + if (linkUserId && !isAnonymousUserId(linkUserId)) { + linkedUserId = linkUserId; + } + 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 { + 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 NextResponse.json({ + ok: true, + vip, + alreadyLinked: !!email, + email: email ?? undefined, + linkedUserId, + }); + } catch (error) { + console.error("[check-by-user] failed:", error); + return NextResponse.json({ ok: false, vip: false }, { status: 500 }); + } +} diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts index 9bdc5f9..72e2de1 100644 --- a/app/api/vip/check/route.ts +++ b/app/api/vip/check/route.ts @@ -1,54 +1,85 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; +import { COOKIE_NAME } from "@/app/lib/auth-cookie"; +import { + findVipEmailLinkByUserId, + getActiveSiteVipRecord, + getAdminToken, + hasVipByUserId, + isAnonymousUserId, +} from "@/app/api/vip/_helpers"; -const COOKIE_NAME = "pb_session"; +type SessionPayload = { + userId?: string; + anonymousUserId?: string; +}; -async function getAdminToken(): Promise { - const email = process.env.POCKETBASE_EMAIL; - const password = process.env.POCKETBASE_PASSWORD; - if (!email || !password) return null; - const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!res.ok) return null; - const data = await res.json(); - return data?.token ?? null; +function readSessionPayload(request: NextRequest): SessionPayload | null { + const raw = request.cookies.get(COOKIE_NAME)?.value; + if (!raw) { + return null; + } + + try { + return JSON.parse(Buffer.from(raw, "base64url").toString("utf-8")); + } catch { + return null; + } } export async function GET(request: NextRequest) { - const cookie = request.cookies.get(COOKIE_NAME)?.value; - if (!cookie) { + const payload = readSessionPayload(request); + const userId = + typeof payload?.userId === "string" ? payload.userId.trim() : ""; + const anonymousUserId = + typeof payload?.anonymousUserId === "string" + ? payload.anonymousUserId.trim() + : ""; + + if (!userId) { return NextResponse.json({ ok: true, vip: false }); } + try { - const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); - const userId = payload?.userId; - if (!userId) return NextResponse.json({ ok: true, vip: false }); + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json({ ok: true, vip: false }); + } - const token = await getAdminToken(); - if (!token) return NextResponse.json({ ok: true, vip: false }); + const userRecord = await getActiveSiteVipRecord(adminToken, userId); + if (userRecord) { + return NextResponse.json({ + ok: true, + vip: true, + expires_at: + typeof userRecord.expires_at === "string" ? userRecord.expires_at : null, + }); + } - const pb = getPocketBaseConfig(); - const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`; - const res = await fetch( - `${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`, - { headers: { Authorization: `Bearer ${token}` } } - ); - if (!res.ok) return NextResponse.json({ ok: true, vip: false }); - const data = await res.json(); - const items = data?.items ?? []; - const record = items[0]; - const now = new Date().toISOString(); - const isExpired = record?.expires_at && record.expires_at < now; - const vip = !!record && !isExpired; - return NextResponse.json({ - ok: true, - vip, - expires_at: record?.expires_at ?? null, - }); + if (await hasVipByUserId(adminToken, userId)) { + return NextResponse.json({ ok: true, vip: true, expires_at: null }); + } + + const candidates = isAnonymousUserId(anonymousUserId) + ? [anonymousUserId] + : []; + if (!isAnonymousUserId(userId)) { + const linkRecord = await findVipEmailLinkByUserId(adminToken, userId); + const linkedAnonymousUserId = + typeof linkRecord?.anonymous_user_id === "string" + ? linkRecord.anonymous_user_id.trim() + : ""; + if (isAnonymousUserId(linkedAnonymousUserId)) { + candidates.push(linkedAnonymousUserId); + } + } + + for (const candidate of candidates) { + if (await hasVipByUserId(adminToken, candidate)) { + return NextResponse.json({ ok: true, vip: true, expires_at: null }); + } + } + + return NextResponse.json({ ok: true, vip: false }); } catch { return NextResponse.json({ ok: true, vip: false }); } diff --git a/app/api/vip/linked-email/route.ts b/app/api/vip/linked-email/route.ts new file mode 100644 index 0000000..cccee87 --- /dev/null +++ b/app/api/vip/linked-email/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; +import { + findAnonymousUserIdForUser, + findLinkedMemberFromOrder, + findVipEmailLinkByAnonymousUserId, + findVipEmailLinkByUserId, + getAdminToken, + isAnonymousUserId, + upsertVipEmailLink, +} from "@/app/api/vip/_helpers"; + +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) || + process.env.PAYMENT_API_URL || + process.env.NEXT_PUBLIC_API_URL || + payConfig.apiUrl + ).replace(/\/$/, ""); +} + +async function getLinkedEmailFromPayjsapi( + request: NextRequest, + userId: string +): Promise { + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch( + `${apiUrl}/nomadvip/check_vip?user_id=${encodeURIComponent(userId)}`, + { cache: "no-store", signal: AbortSignal.timeout(5000) } + ); + if (!res.ok) { + return null; + } + + const data = await res.json().catch(() => ({})); + const email = + typeof data?.email === "string" ? data.email.trim().toLowerCase() : ""; + return data?.vip && email ? email : null; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const userId = request.nextUrl.searchParams.get("user_id")?.trim() || ""; + if (!userId) { + return NextResponse.json( + { email: null, error: "缺少 user_id" }, + { status: 400 } + ); + } + + try { + const adminToken = await getAdminToken(); + if (!adminToken) { + return NextResponse.json({ email: null }, { status: 500 }); + } + + const linkRecord = isAnonymousUserId(userId) + ? await findVipEmailLinkByAnonymousUserId(adminToken, userId) + : await findVipEmailLinkByUserId(adminToken, userId); + const linkedEmail = + typeof linkRecord?.email === "string" + ? linkRecord.email.trim().toLowerCase() + : ""; + const anonymousUserId = + typeof linkRecord?.anonymous_user_id === "string" && + isAnonymousUserId(linkRecord.anonymous_user_id.trim()) + ? linkRecord.anonymous_user_id.trim() + : !isAnonymousUserId(userId) + ? await findAnonymousUserIdForUser(adminToken, userId) + : isAnonymousUserId(userId) + ? userId + : null; + if (linkedEmail) { + return NextResponse.json({ + email: linkedEmail, + anonymousUserId, + }); + } + + if (isAnonymousUserId(userId)) { + const linkedMember = await findLinkedMemberFromOrder(adminToken, userId); + if (linkedMember) { + await upsertVipEmailLink( + adminToken, + linkedMember.email, + linkedMember.userId, + userId + ); + return NextResponse.json({ + email: linkedMember.email, + anonymousUserId: userId, + }); + } + } + + const payjsEmail = await getLinkedEmailFromPayjsapi(request, userId); + if (payjsEmail) { + if (anonymousUserId && !isAnonymousUserId(userId)) { + await upsertVipEmailLink(adminToken, payjsEmail, userId, anonymousUserId); + } + return NextResponse.json({ + email: payjsEmail, + anonymousUserId: anonymousUserId ?? (isAnonymousUserId(userId) ? userId : null), + }); + } + + return NextResponse.json({ email: null, anonymousUserId: anonymousUserId ?? null }); + } catch (error) { + console.error("[linked-email] failed:", error); + return NextResponse.json({ email: null, anonymousUserId: null }, { status: 500 }); + } +} diff --git a/app/lib/usePayStatusPoll.ts b/app/lib/usePayStatusPoll.ts index d85ee94..591ac3b 100644 --- a/app/lib/usePayStatusPoll.ts +++ b/app/lib/usePayStatusPoll.ts @@ -1,13 +1,9 @@ -/** - * 支付状态轮询 - * - 手机 H5:传入 orderId(点击支付后由 state 传入),直接轮询,不依赖 sessionStorage/cookie - * - PC/微信:不传 orderId,使用 cookie 轮询(兼容旧逻辑) - */ import { useEffect, useRef, useState } from "react"; const COOKIE_NAME = "nomadvip_pay_order"; -const POLL_INTERVAL = 1000; -const MAX_POLLS = 160; // ~5 分钟 +const STORAGE_NAME = "nomadvip_pay_order"; +const POLL_INTERVAL_MS = 1200; +const MAX_POLLS = 30; const MAX_AGE_MS = 15 * 60 * 1000; function getCookie(name: string): string | null { @@ -21,6 +17,21 @@ function clearCookie(name: string): void { document.cookie = `${name}=; path=/; max-age=0`; } +function getStorage(name: string): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(name); +} + +function removeStorage(name: string): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(name); +} + +function clearPendingOrder(): void { + clearCookie(COOKIE_NAME); + removeStorage(STORAGE_NAME); +} + function parsePayOrderCookie(raw: string | null): [string | null, boolean] { if (!raw?.trim()) return [null, false]; const parts = raw.split("|"); @@ -35,7 +46,6 @@ function parsePayOrderCookie(raw: string | null): [string | null, boolean] { export function usePayStatusPoll( onPaid: (orderId?: string) => void, onTimeout?: () => void, - /** 手机 H5:点击支付后传入的 orderId,直接轮询,不依赖 sessionStorage/cookie */ orderIdFromState?: string | null ): boolean { const [polling, setPolling] = useState(false); @@ -47,43 +57,101 @@ export function usePayStatusPoll( useEffect(() => { const orderId = orderIdFromState !== undefined - ? (orderIdFromState?.trim() || null) + ? orderIdFromState?.trim() || null : (() => { - const raw = getCookie(COOKIE_NAME); - const [oid, valid] = parsePayOrderCookie(raw); - return valid ? oid : null; + const cookieRaw = getCookie(COOKIE_NAME); + const [cookieOrderId, cookieValid] = parsePayOrderCookie(cookieRaw); + if (cookieValid) { + return cookieOrderId; + } + + const storageRaw = getStorage(STORAGE_NAME); + const [storageOrderId, storageValid] = parsePayOrderCookie(storageRaw); + return storageValid ? storageOrderId : null; })(); + if (!orderId) { - if (orderIdFromState === undefined && getCookie(COOKIE_NAME)) clearCookie(COOKIE_NAME); + if ( + orderIdFromState === undefined && + (getCookie(COOKIE_NAME) || getStorage(STORAGE_NAME)) + ) { + clearPendingOrder(); + } return; } setPolling(true); - let count = 0; - const timer = setInterval(async () => { - count++; - if (count > MAX_POLLS) { - clearInterval(timer); - clearCookie(COOKIE_NAME); + let attempts = 0; + let stopped = false; + let timer: number | undefined; + let inFlight = false; + + const stop = () => { + stopped = true; + if (timer) window.clearTimeout(timer); + }; + + const tick = async () => { + if (stopped) return; + if (inFlight) { + scheduleNext(); + return; + } + + attempts += 1; + if (attempts > MAX_POLLS) { + stop(); + clearPendingOrder(); setPolling(false); onTimeoutRef.current?.(); return; } + + inFlight = true; try { - const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`); - const data = await res.json(); + const res = await fetch( + `/api/pay/status?order_id=${encodeURIComponent(orderId)}`, + { cache: "no-store" } + ); + const data = await res.json().catch(() => ({})); if (data?.ok && data?.paid) { - clearInterval(timer); - clearCookie(COOKIE_NAME); + stop(); + clearPendingOrder(); setPolling(false); onPaidRef.current(orderId); + return; } } catch { - // 继续轮询 + // ignore and continue polling + } finally { + inFlight = false; } - }, POLL_INTERVAL); - return () => clearInterval(timer); + scheduleNext(); + }; + + const scheduleNext = () => { + if (stopped) return; + timer = window.setTimeout(tick, POLL_INTERVAL_MS); + }; + + const handleResume = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void tick(); + }; + + document.addEventListener("visibilitychange", handleResume); + window.addEventListener("pageshow", handleResume); + window.addEventListener("focus", handleResume); + void tick(); + return () => { + document.removeEventListener("visibilitychange", handleResume); + window.removeEventListener("pageshow", handleResume); + window.removeEventListener("focus", handleResume); + stop(); + }; }, [orderIdFromState]); return polling; diff --git a/app/lib/xorpayStatus.ts b/app/lib/xorpayStatus.ts new file mode 100644 index 0000000..a1a3a42 --- /dev/null +++ b/app/lib/xorpayStatus.ts @@ -0,0 +1,67 @@ +import { createHash } from "crypto"; + +const XORPAY_AID = process.env.XORPAY_AID || "8220"; +const XORPAY_SECRET = + process.env.XORPAY_SECRET || "afcacd99570945f88de62624aaa3578e"; +const XORPAY_QUERY_URL = + process.env.XORPAY_QUERY_URL || "https://xorpay.com/api/query2"; +const XORPAY_QUERY_TIMEOUT_MS = Number( + process.env.XORPAY_QUERY_TIMEOUT_MS || 5000 +); + +export type XorPayStatusResult = { + ok: boolean; + paid: boolean; + status?: string; + error?: string; + url: string; +}; + +function buildXorPaySign(orderId: string): string { + return createHash("md5") + .update(`${orderId}${XORPAY_SECRET}`, "utf8") + .digest("hex") + .toLowerCase(); +} + +export async function queryXorPayOrderStatus( + orderId: string, + timeoutMs = XORPAY_QUERY_TIMEOUT_MS +): Promise { + const normalizedOrderId = orderId.trim(); + if (!normalizedOrderId || !XORPAY_AID || !XORPAY_SECRET) { + return null; + } + + const url = new URL( + `${XORPAY_QUERY_URL.replace(/\/$/, "")}/${encodeURIComponent(XORPAY_AID)}` + ); + url.searchParams.set("order_id", normalizedOrderId); + url.searchParams.set("sign", buildXorPaySign(normalizedOrderId)); + + try { + const res = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }); + const data = await res.json().catch(() => ({})); + const status = String(data?.status || "") + .trim() + .toLowerCase(); + + return { + ok: res.ok, + paid: res.ok && (status === "payed" || status === "success"), + status, + url: url.toString(), + error: res.ok ? undefined : `status=${res.status}`, + }; + } catch (error) { + return { + ok: false, + paid: false, + url: url.toString(), + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/app/lib/zpayStatus.ts b/app/lib/zpayStatus.ts new file mode 100644 index 0000000..92a65e0 --- /dev/null +++ b/app/lib/zpayStatus.ts @@ -0,0 +1,59 @@ +const ZPAY_PID = process.env.ZPAY_PID || "2025121809351743"; +const ZPAY_KEY = process.env.ZPAY_KEY || "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89"; +const ZPAY_QUERY_URL = + process.env.ZPAY_QUERY_URL || "https://zpayz.cn/api.php"; +const ZPAY_QUERY_TIMEOUT_MS = Number( + process.env.ZPAY_QUERY_TIMEOUT_MS || 5000 +); + +export type ZPayStatusResult = { + ok: boolean; + paid: boolean; + status?: string; + error?: string; + url: string; +}; + +export async function queryZPayOrderStatus( + orderId: string, + timeoutMs = ZPAY_QUERY_TIMEOUT_MS +): Promise { + const normalizedOrderId = orderId.trim(); + if (!normalizedOrderId || !ZPAY_PID || !ZPAY_KEY) { + return null; + } + + const url = new URL(ZPAY_QUERY_URL); + url.searchParams.set("act", "order"); + url.searchParams.set("pid", ZPAY_PID); + url.searchParams.set("key", ZPAY_KEY); + url.searchParams.set("out_trade_no", normalizedOrderId); + + try { + const res = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }); + const data = await res.json().catch(() => ({})); + const code = String(data?.code || "").trim(); + const status = String(data?.status || "").trim(); + + return { + ok: res.ok, + paid: res.ok && code === "1" && status === "1", + status, + url: url.toString(), + error: + res.ok && code === "1" + ? undefined + : String(data?.msg || `status=${res.status}`), + }; + } catch (error) { + return { + ok: false, + paid: false, + url: url.toString(), + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/app/page.tsx b/app/page.tsx index 10fe315..6b43aac 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,21 +1,21 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import PocketBase from "pocketbase"; import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv"; import { getPaymentConfig } from "@/config/services.config"; import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll"; import { VipLayout } from "@/app/vip/components/VipLayout"; import { LandingPage } from "@/app/vip/components/LandingPage"; import { DashboardPage } from "@/app/vip/components/DashboardPage"; -import { PayAuthModal } from "@/app/vip/components/landing/PayAuthModal"; import styles from "@/app/vip/components/vip.module.css"; -const PB_URL = - process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn"; - /** 微信内支付渠道:zpay | xorpay */ const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay"; +const DEFAULT_EMAIL_PASSWORD = "12345678"; +const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid"; +const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; +const PAY_ORDER_COOKIE = "nomadvip_pay_order"; +const PAY_ORDER_MAX_AGE = 60 * 15; function redirectToPayZpay(params: { user_id: string; @@ -57,7 +57,7 @@ async function redirectToPayZpayH5(params: { total_fee: number; type: string; channel: "alipay" | "wxpay"; -}): Promise { +}, onPendingOrder?: (orderId: string) => void): Promise { const res = await fetch("/api/pay", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -74,14 +74,18 @@ async function redirectToPayZpayH5(params: { return null; } const orderId = data?.order_id?.trim() || null; + if (orderId) { + savePendingOrder(orderId); + onPendingOrder?.(orderId); + } if (data?.redirect_url) { - window.open(data.redirect_url, "_blank"); + window.location.href = data.redirect_url; return orderId; } const form = document.createElement("form"); form.method = "POST"; form.action = data.payUrl; - form.target = "_blank"; + form.target = "_self"; form.style.display = "none"; for (const [k, v] of Object.entries(data.params as Record)) { if (v == null) continue; @@ -123,6 +127,10 @@ async function payWechatXorpay(params: { const form = document.createElement("form"); form.method = "POST"; form.action = data.payUrl; + const orderId = data?.order_id?.trim(); + if (orderId) { + savePendingOrder(orderId); + } form.target = "_self"; form.style.display = "none"; for (const [k, v] of Object.entries(data.params as Record)) { @@ -152,13 +160,65 @@ function removeStorage(key: string): void { localStorage.removeItem(key); } +function getCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +function setAnonymousUserCookie(userId: string): void { + if (typeof document === "undefined") return; + if (!/^user\d+$/.test(userId)) return; + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${ANONYMOUS_USER_COOKIE}=${encodeURIComponent( + userId + )}; Path=/; Max-Age=${ANONYMOUS_USER_COOKIE_MAX_AGE}; SameSite=Lax${secure}`; +} + +function clearCookie(name: string): void { + if (typeof document === "undefined") return; + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`; +} + +function clearAnonymousUserCookie(): void { + clearCookie(ANONYMOUS_USER_COOKIE); +} + +function setPayOrderCookie(orderId: string): void { + if (typeof document === "undefined") return; + const normalizedOrderId = orderId.trim(); + if (!normalizedOrderId) return; + const secure = window.location.protocol === "https:" ? "; Secure" : ""; + document.cookie = `${PAY_ORDER_COOKIE}=${encodeURIComponent( + `${normalizedOrderId}|${Date.now()}` + )}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`; +} + +function savePendingOrder(orderId: string): void { + const normalizedOrderId = orderId.trim(); + if (!normalizedOrderId) return; + setPayOrderCookie(normalizedOrderId); + setStorage(PAY_ORDER_COOKIE, `${normalizedOrderId}|${Date.now()}`); +} + +function clearPendingOrder(): void { + clearCookie(PAY_ORDER_COOKIE); + removeStorage(PAY_ORDER_COOKIE); +} + +function getPendingOrderId(): string | null { + const raw = getCookie(PAY_ORDER_COOKIE) || getStorage(PAY_ORDER_COOKIE); + const orderId = raw?.split("|")[0]?.trim() || ""; + return orderId || null; +} + export default function Home() { const [paidType, setPaidType] = useState(null); const [userName, setUserName] = useState(null); const [userEmail, setUserEmail] = useState(null); const [loading, setLoading] = useState(true); const [payPendingOrderId, setPayPendingOrderId] = useState(null); - const [payAuthModalOpen, setPayAuthModalOpen] = useState(false); const savePaidType = useCallback((type: string | null) => { if (type) setStorage("paidType", type); @@ -171,8 +231,132 @@ export default function Home() { setUserName(name); }, []); + const getStoredAnonymousUserId = useCallback((): string | null => { + const memosAccount = getStorage("memosAccount"); + if (memosAccount && /^user\d+$/.test(memosAccount)) { + return memosAccount; + } + + const userId = getStorage("userId"); + if (userId && /^user\d+$/.test(userId)) { + return userId; + } + + const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE); + if (cookieUserId && /^user\d+$/.test(cookieUserId)) { + setStorage("memosAccount", cookieUserId); + return cookieUserId; + } + + return null; + }, []); + + const applyRecoveredEmailSession = useCallback( + async ( + data: { + token?: string; + record?: { id?: string; email?: string }; + anonymousUserId?: string | null; + memosAccount?: string | null; + }, + normalizedEmail: string, + fallbackAnonymousUserId?: string + ) => { + if (!data?.token || !data?.record?.id) { + return false; + } + + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, data.record); + + const anonymousUserId = + (typeof data?.anonymousUserId === "string" && + /^user\d+$/.test(data.anonymousUserId) + ? data.anonymousUserId + : typeof data?.memosAccount === "string" && + /^user\d+$/.test(data.memosAccount) + ? data.memosAccount + : /^user\d+$/.test(fallbackAnonymousUserId?.trim() || "") + ? fallbackAnonymousUserId?.trim() + : "") || ""; + + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: data.token, + record: data.record, + anonymousUserId: anonymousUserId || undefined, + }), + credentials: "include", + }); + + setStorage("userId", data.record.id); + setStorage("userEmail", normalizedEmail); + setStorage("userName", normalizedEmail); + setStorage("emailLinked", "1"); + if (anonymousUserId) { + setStorage("memosAccount", anonymousUserId); + setAnonymousUserCookie(anonymousUserId); + } + + savePaidType("vip"); + setUserEmail(normalizedEmail); + saveUserName(normalizedEmail); + return true; + }, + [savePaidType, saveUserName] + ); + + const recoverLinkedVipSession = useCallback( + async ( + email: string, + password: string, + user_id?: string + ): Promise<{ ok: boolean; error?: string }> => { + try { + const normalizedEmail = email.trim().toLowerCase(); + const res = await fetch("/api/vip/check-by-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: normalizedEmail, + password, + ...(user_id?.trim() && { user_id: user_id.trim() }), + }), + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!data?.vip || !data?.token || !data?.record) { + return { + ok: false, + error: + data?.error || + "未找到关联的会员记录,请确认已支付并绑定邮箱", + }; + } + + const ok = await applyRecoveredEmailSession( + data, + normalizedEmail, + user_id + ); + return ok ? { ok: true } : { ok: false, error: "登录恢复失败" }; + } catch (error) { + const msg = error instanceof Error ? error.message : "验证失败"; + return { ok: false, error: msg }; + } + }, + [applyRecoveredEmailSession] + ); + const getOrCreateUserId = useCallback(async () => { - // 优先使用 PocketBase 用户 ID(已登录时,含跨站 Cookie) + const anonymousUserId = getStoredAnonymousUserId(); + if (anonymousUserId) { + setStorage("userId", anonymousUserId); + setAnonymousUserCookie(anonymousUserId); + return anonymousUserId; + } try { const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); const meData = await meRes.json(); @@ -201,78 +385,109 @@ export default function Home() { setStorage("userId", userId); } return userId; - }, []); + }, [getStoredAnonymousUserId]); - const checkPaymentFromPB = useCallback( + /** user_id 验证:调用后端 API(优先 payjsapi,回退 PocketBase) */ + const checkVerifyByUserId = useCallback( async (userId: string): Promise => { try { - const pb = new PocketBase(PB_URL); - const records = await pb.collection("payments").getList(1, 1, { - filter: `user_id = "${userId}"`, - sort: "-created", + const normalizedUserId = userId.trim(); + const res = await fetch("/api/vip/check-by-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: normalizedUserId }), }); - - if (records.items.length > 0) { - const latestType = records.items[0].type as string; - const dbUserId = records.items[0].user_id as string; - setStorage("userId", dbUserId); - savePaidType(latestType); - saveUserName(dbUserId); - return latestType === "vip"; + const data = await res.json().catch(() => ({})); + if (data?.vip) { + setStorage("userId", normalizedUserId); + if (/^user\d+$/.test(normalizedUserId)) { + setStorage("memosAccount", normalizedUserId); + setAnonymousUserCookie(normalizedUserId); + } + savePaidType("vip"); + if (data?.alreadyLinked && data?.email) { + const linkedEmail = String(data.email).trim().toLowerCase(); + setStorage("emailLinked", "1"); + setStorage("userEmail", linkedEmail); + setStorage("userName", linkedEmail); + setUserEmail(linkedEmail); + saveUserName(linkedEmail); + await recoverLinkedVipSession( + linkedEmail, + DEFAULT_EMAIL_PASSWORD, + normalizedUserId + ).catch(() => {}); + } else { + const linked = getStorage("emailLinked") === "1"; + const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@"); + if (!linked && !hasEmail) saveUserName(normalizedUserId); + } + return true; } savePaidType(null); return false; } catch (err) { - console.error("查询 PocketBase 失败:", err); + console.error("验证失败:", err); return false; } }, - [savePaidType, saveUserName] + [recoverLinkedVipSession, savePaidType, saveUserName] ); - /** 会员账号恢复:通过邮箱+密码验证,关联 users 后检查 site_vip / payments */ - const checkVerifyByEmail = useCallback( - async (email: string, password: string): Promise => { - try { - const { pbLogin, pbSaveAuth } = await import("@/app/lib/pocketbase"); - const result = await pbLogin(email, password); - pbSaveAuth(result.token, result.record); - await fetch("/api/auth/sync-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: result.token, record: result.record }), - credentials: "include", - }); - const userId = result.record.id; - const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); - const vipData = await vipRes.json(); - if (vipData?.vip) { - setStorage("userId", userId); - savePaidType("vip"); - saveUserName(email); - return true; - } - const found = await checkPaymentFromPB(userId); - if (found) return true; - return false; - } catch { + const checkPaymentFromPB = checkVerifyByUserId; + + const tryRecoverByAnonymousUserId = useCallback( + async (anonymousUserId: string): Promise => { + if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) { return false; } + + const firstHit = await checkPaymentFromPB(anonymousUserId); + if (firstHit) { + return true; + } + + const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat"; + if (!isWechat) { + return false; + } + + // WeChat can close page immediately after pay success; notify write may lag. + for (let attempt = 0; attempt < 4; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + const recovered = await checkPaymentFromPB(anonymousUserId); + if (recovered) { + return true; + } + } + + return false; }, - [checkPaymentFromPB, savePaidType, saveUserName] + [checkPaymentFromPB] + ); + + /** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP,可选 user_id 用于自动关联 */ + const checkVerifyByEmail = useCallback( + async ( + email: string, + password: string, + user_id?: string + ): Promise<{ ok: boolean; error?: string }> => { + return recoverLinkedVipSession(email, password, user_id); + }, + [recoverLinkedVipSession] ); const clearAllStorage = useCallback(() => { try { if (typeof window === "undefined") return; - const keys = ["userId", "paidType", "userName"]; - keys.forEach((k) => { - try { - localStorage.removeItem(k); - } catch { - /* ignore */ - } - }); + try { + localStorage.clear(); + } catch { + /* ignore */ + } + clearAnonymousUserCookie(); + clearPendingOrder(); setPaidType(null); setUserName(null); setLoading(false); @@ -285,6 +500,11 @@ export default function Home() { const doPay = useCallback(async () => { const userId = await getOrCreateUserId(); + if (/^user\d+$/.test(userId)) { + setStorage("memosAccount", userId); + setStorage("userId", userId); + setAnonymousUserCookie(userId); + } const returnUrl = `${window.location.origin}/paid`; const env = getPayEnv(); const device = getDeviceFromEnv(env); @@ -299,29 +519,14 @@ export default function Home() { if (device === "wechat" && WECHAT_PAY_PROVIDER === "xorpay") { payWechatXorpay(base); } else if (device === "h5") { - const orderId = await redirectToPayZpayH5(base); + const orderId = await redirectToPayZpayH5(base, setPayPendingOrderId); if (orderId) setPayPendingOrderId(orderId); } else { redirectToPayZpay({ ...base, device }); } }, [getOrCreateUserId]); - const handlePay = useCallback(async () => { - try { - const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); - const meData = await meRes.json(); - if (meData?.user?.id) { - doPay(); - return; - } - } catch { - /* ignore */ - } - setPayAuthModalOpen(true); - }, [doPay]); - - const handlePayAuthSuccess = useCallback(() => { - setPayAuthModalOpen(false); + const handlePay = useCallback(() => { doPay(); }, [doPay]); @@ -333,22 +538,47 @@ export default function Home() { const device = getDeviceFromEnv(env); const isH5 = device === "h5"; usePayStatusPoll( - (orderId) => { + async (orderId) => { let uid = getStorage("userId"); if (!uid && orderId?.includes("_order_")) { const prefix = orderId.split("_order_")[0]; const parts = prefix.split("_"); if (parts.length >= 2) uid = parts.slice(0, -1).join("_"); } - if (uid) saveUserName(uid); - savePaidType("vip"); + if (uid) { + let confirmed = false; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const confirmRes = await fetch("/api/pay/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ order_id: orderId, user_id: uid }), + }); + const confirmData = await confirmRes.json().catch(() => ({})); + if (confirmData?.ok) { + confirmed = true; + break; + } + } catch (e) { + console.error("pay/confirm request error:", e); + } + if (attempt < 2) await new Promise((r) => setTimeout(r, 1000)); + } + if (confirmed) { + saveUserName(uid); + savePaidType("vip"); + } + /* confirm 失败时不设置 paidType,避免显示 VIP 但 PB/Memos 无记录 */ + } + clearPendingOrder(); if (isH5) setPayPendingOrderId(null); window.location.reload(); }, () => { + clearPendingOrder(); if (isH5) setPayPendingOrderId(null); }, - isH5 ? payPendingOrderId : undefined + isH5 ? payPendingOrderId ?? undefined : undefined ); useEffect(() => { @@ -357,30 +587,75 @@ export default function Home() { clearAllStorage(); return; } - if (urlParams.get("paid") === "1") { - savePaidType("vip"); - saveUserName(getStorage("userId") || ""); - } + /* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */ const init = async () => { + const pendingOrderId = getPendingOrderId(); + if (pendingOrderId) { + setPayPendingOrderId(pendingOrderId); + setLoading(false); + return; + } + // 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖 const localType = getStorage("paidType"); const localUserName = getStorage("userName"); + const localUserEmail = getStorage("userEmail"); const localUserId = getStorage("userId"); - if (localType === "vip" && localUserName && localUserId) { - setPaidType(localType); - setUserName(localUserName); - setLoading(false); - return; + if (localType === "vip" && (localUserName || localUserEmail) && localUserId) { + const isAnonymous = /^user\d+$/.test(localUserId); + let verified = false; + if (isAnonymous) { + verified = await checkPaymentFromPB(localUserId); + } else { + const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); + const vipData = await vipRes.json(); + verified = !!vipData?.vip; + } + if (verified) { + setPaidType(localType); + setUserName(localUserEmail || localUserName || ""); + setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null)); + if (!localUserEmail && !localUserName?.includes("@")) { + try { + const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); + const meData = await meRes.json(); + if (meData?.user?.email) setUserEmail(meData.user.email); + } catch { /* ignore */ } + } + setLoading(false); + return; + } else { + removeStorage("paidType"); + removeStorage("userName"); + removeStorage("userEmail"); + } + } + + const anonymousCandidate = getStoredAnonymousUserId(); + if (anonymousCandidate) { + const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate); + if (recovered) { + setStorage("userId", anonymousCandidate); + setStorage("memosAccount", anonymousCandidate); + setAnonymousUserCookie(anonymousCandidate); + const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate; + setUserName(name); + setUserEmail(name.includes("@") ? name : getStorage("userEmail")); + setLoading(false); + return; + } } await getOrCreateUserId(); const afterLocalType = getStorage("paidType"); const afterLocalUserName = getStorage("userName"); - if (afterLocalType && afterLocalUserName) { + const afterLocalUserEmail = getStorage("userEmail"); + if (afterLocalType && (afterLocalUserName || afterLocalUserEmail)) { setPaidType(afterLocalType); - setUserName(afterLocalUserName); + setUserName(afterLocalUserEmail || afterLocalUserName || ""); + setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null)); setLoading(false); return; } @@ -396,7 +671,21 @@ export default function Home() { const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); const vipData = await vipRes.json(); if (vipData?.vip) { + // 匿名 id 不覆盖;仅当当前无匿名 id 时才写入(可能是纯邮箱登录) + const cur = getStorage("userId"); + if (cur && /^user\d+$/.test(cur)) { + setStorage("memosAccount", cur); + } else if ( + typeof meData.user.anonymousUserId === "string" && + /^user\d+$/.test(meData.user.anonymousUserId) + ) { + setStorage("memosAccount", meData.user.anonymousUserId); + } setStorage("userId", meData.user.id); + setStorage("emailLinked", "1"); + if (meData.user.email) { + setStorage("userEmail", meData.user.email); + } savePaidType("vip"); saveUserName(meData.user.email || meData.user.id); setLoading(false); @@ -407,26 +696,63 @@ export default function Home() { /* ignore */ } - const userId = getStorage("userId"); - if (userId) { - const found = await checkPaymentFromPB(userId); + const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId"); + if (userIdForCheck) { + const found = await checkPaymentFromPB(userIdForCheck); if (found) { - setUserName(getStorage("userName")); + const name = getStorage("userEmail") || getStorage("userName"); + if (name) setUserName(name); } } setLoading(false); }; init(); - }, [clearAllStorage, getOrCreateUserId, checkPaymentFromPB, savePaidType, saveUserName]); + }, [ + clearAllStorage, + getOrCreateUserId, + getStoredAnonymousUserId, + checkPaymentFromPB, + savePaidType, + saveUserName, + tryRecoverByAnonymousUserId, + ]); + + useEffect(() => { + if (!isH5) return; + + const syncPendingOrder = () => { + const pendingOrderId = getPendingOrderId(); + if (!pendingOrderId) return; + setPayPendingOrderId((current) => + current === pendingOrderId ? current : pendingOrderId + ); + }; + + const onVisible = () => { + if (document.visibilityState === "visible") { + syncPendingOrder(); + } + }; + + syncPendingOrder(); + document.addEventListener("visibilitychange", onVisible); + window.addEventListener("pageshow", syncPendingOrder); + window.addEventListener("focus", syncPendingOrder); + return () => { + document.removeEventListener("visibilitychange", onVisible); + window.removeEventListener("pageshow", syncPendingOrder); + window.removeEventListener("focus", syncPendingOrder); + }; + }, [isH5]); useEffect(() => { const syncFromStorage = () => { const localType = getStorage("paidType"); - const localUserName = getStorage("userName"); - if (localType && localUserName && !paidType) { + const localName = getStorage("userEmail") || getStorage("userName"); + if (localType && localName && !paidType) { setPaidType(localType); - setUserName(localUserName); + setUserName(localName); } }; const onVisible = () => syncFromStorage(); @@ -465,7 +791,7 @@ export default function Home() { <> {isLoggedIn ? ( - + ) : ( <> - setPayAuthModalOpen(false)} - onSuccess={handlePayAuthSuccess} - /> )} diff --git a/app/paid/page.tsx b/app/paid/page.tsx index 034be4f..82737a7 100644 --- a/app/paid/page.tsx +++ b/app/paid/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll"; const COOKIE_NAME = "nomadvip_pay_order"; +const DEFAULT_PASSWORD = "12345678"; function getCookie(name: string): string | null { if (typeof document === "undefined") return null; @@ -11,6 +12,10 @@ function getCookie(name: string): string | null { return match ? decodeURIComponent(match[1]) : null; } +function isAnonymousUserId(value: string | null | undefined): value is string { + return !!value && /^user\d+$/.test(value); +} + function parseUserIdFromOrderId(orderId: string): string { if (!orderId?.includes("_order_")) return ""; const prefix = orderId.split("_order_")[0]; @@ -18,72 +23,248 @@ function parseUserIdFromOrderId(orderId: string): string { return parts.length >= 2 ? parts.slice(0, -1).join("_") : ""; } -function SuccessWithRedirect() { +function getOrderIdFromPage(): string | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const fromUrl = + params.get("order_id") || params.get("out_trade_no") || params.get("orderId"); + if (fromUrl?.trim()) return fromUrl.trim(); + const raw = getCookie(COOKIE_NAME); + if (!raw?.trim()) return null; + return raw.split("|")[0]?.trim() || null; +} + +function persistAnonymousVip(userId: string): void { + if (typeof window === "undefined") return; + localStorage.setItem("userId", userId); + localStorage.setItem("memosAccount", userId); + localStorage.setItem("paidType", "vip"); + if (localStorage.getItem("emailLinked") !== "1" && !localStorage.getItem("userEmail")) { + localStorage.setItem("userName", userId); + } +} + +async function confirmOrder(orderId: string, userId: string): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch("/api/pay/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ order_id: orderId, user_id: userId }), + }); + const data = await res.json().catch(() => ({})); + if (data?.ok) return true; + } catch (error) { + console.error("pay/confirm request error:", error); + } + + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + return false; +} + +function SuccessPanel({ anonymousUserId }: { anonymousUserId: string | null }) { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { - const t = setTimeout(() => { + if (!anonymousUserId || !isAnonymousUserId(anonymousUserId)) { + const timer = window.setTimeout(() => { + window.location.href = "/"; + }, 600); + return () => window.clearTimeout(timer); + } + }, [anonymousUserId]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail || !anonymousUserId) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/auth/link-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: normalizedEmail, + password: DEFAULT_PASSWORD, + anonymousUserId, + }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data?.ok || !data?.token || !data?.record?.id) { + setError(data?.error || "绑定邮箱失败"); + return; + } + + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, data.record); + + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: data.token, + record: data.record, + anonymousUserId, + }), + credentials: "include", + }); + + localStorage.setItem("memosAccount", anonymousUserId); + localStorage.setItem("userId", data.record.id); + localStorage.setItem("userEmail", data.record.email || normalizedEmail); + localStorage.setItem("userName", data.record.email || normalizedEmail); + localStorage.setItem("emailLinked", "1"); + localStorage.setItem("paidType", "vip"); + window.location.href = "/"; - }, 2000); - return () => clearTimeout(t); - }, []); + } catch (submitError) { + console.error("link-email failed:", submitError); + setError("绑定邮箱失败"); + } finally { + setLoading(false); + } + }; + return (
-
+
- ✅ + ✓

支付成功

-

感谢您的支持,VIP 权益已开通

-

2 秒后自动返回首页

- { e.preventDefault(); window.location.href = "/"; }} - className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600" - > - ← 立即返回首页 - +

+ 最后一步,绑定邮箱后再进入 VIP。后续可以在多个站点共用同一账号。 +

+ {isAnonymousUserId(anonymousUserId) ? ( +
+

+ 默认密码为 {DEFAULT_PASSWORD}。绑定后会把当前匿名会员迁移到统一邮箱账号。 +

+ setEmail(event.target.value)} + placeholder="your@email.com" + required + disabled={loading} + className="mb-3 w-full rounded-lg border border-slate-300 px-4 py-2 text-slate-800" + /> + {error ?

{error}

: null} + +
+ ) : ( + <> +

正在返回首页

+ { + event.preventDefault(); + window.location.href = "/"; + }} + className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600" + > + 返回首页 + + + )}
); } export default function PaidPage() { - const [status, setStatus] = useState<"pending" | "success" | "fail">("pending"); - const [fromUrl, setFromUrl] = useState(null); + const [status, setStatus] = useState< + "pending" | "success" | "fail" | "confirm_fail" + >("pending"); + const [confirmedUserId, setConfirmedUserId] = useState(null); + const orderIdFromUrlOrCookie = getOrderIdFromPage(); - usePayStatusPoll((orderId) => { - const uid = parseUserIdFromOrderId(orderId || ""); - const raw = getCookie(COOKIE_NAME); - const oid = raw?.split("|")[0]?.trim() || orderId || ""; - const userId = uid || parseUserIdFromOrderId(oid); - if (userId) { - try { - localStorage.setItem("paidType", "vip"); - localStorage.setItem("userName", userId); - } catch {} - } - setStatus("success"); - }, () => { window.location.href = "/"; }); + usePayStatusPoll( + async (orderId) => { + const finalOrderId = (orderId || orderIdFromUrlOrCookie || "").trim(); + const candidateUserId = + parseUserIdFromOrderId(finalOrderId) || localStorage.getItem("userId"); + + if (!finalOrderId || !candidateUserId) { + setStatus("confirm_fail"); + return; + } + + const confirmed = await confirmOrder(finalOrderId, candidateUserId); + if (confirmed) { + persistAnonymousVip(candidateUserId); + setConfirmedUserId(candidateUserId); + } + setStatus(confirmed ? "success" : "confirm_fail"); + }, + () => { + window.location.href = "/"; + }, + orderIdFromUrlOrCookie + ); useEffect(() => { const params = new URLSearchParams(window.location.search); - const paid = params.get("paid"); - const fail = params.get("fail"); - const from = params.get("from"); - if (paid === "1") setStatus("success"); - else if (fail === "1") setStatus("fail"); - if (from) setFromUrl(decodeURIComponent(from)); + if (params.get("fail") === "1") { + setStatus("fail"); + return; + } + + if (params.get("paid") !== "1") { + return; + } + + const orderId = getOrderIdFromPage(); + const candidateUserId = + (orderId ? parseUserIdFromOrderId(orderId) : "") || localStorage.getItem("userId"); + + if (!orderId || !candidateUserId) { + setStatus("confirm_fail"); + return; + } + + void (async () => { + const confirmed = await confirmOrder(orderId, candidateUserId); + if (confirmed) { + persistAnonymousVip(candidateUserId); + setConfirmedUserId(candidateUserId); + } + setStatus(confirmed ? "success" : "confirm_fail"); + })(); }, []); if (status === "pending") { return (
-
+
-

支付结果确认中

+

正在确认支付结果

- 正在查询支付状态,请稍候… + 正在写入会员记录,请稍候。
- (支付完成后将自动跳转,1 分钟内未支付将返回首页) + 支付完成后会继续进入邮箱绑定。

@@ -91,25 +272,63 @@ export default function PaidPage() { } if (status === "success") { + const anonymousUserId = + confirmedUserId || + (typeof window !== "undefined" ? localStorage.getItem("userId") : null); + return ; + } + + if (status === "confirm_fail") { return ( - +
+
+
+ ! +
+

支付已确认,权益开通中

+

+ 订单已支付,但会员写入可能有延迟。请稍后刷新首页,或联系管理员处理。 +

+ { + event.preventDefault(); + window.location.href = "/"; + }} + className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600" + > + 返回首页 + +
+
); } return (
-
- diff --git a/app/vip/components/DashboardPage.tsx b/app/vip/components/DashboardPage.tsx index c8c6632..9f837b3 100644 --- a/app/vip/components/DashboardPage.tsx +++ b/app/vip/components/DashboardPage.tsx @@ -6,12 +6,39 @@ import { TopicEbookShowcase } from "./landing/TopicEbookShowcase"; type DashboardPageProps = { userName: string | null; + userEmail?: string | null; }; -export function DashboardPage({ userName }: DashboardPageProps) { +function getStorage(key: string): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(key); +} + +function requiresEmailBinding(): boolean { + const userId = getStorage("userId"); + const emailLinked = getStorage("emailLinked") === "1"; + const userEmail = getStorage("userEmail"); + const userName = getStorage("userName"); + + return ( + !!userId && + /^user\d+$/.test(userId) && + !emailLinked && + !userEmail && + !userName?.includes("@") + ); +} + +export function DashboardPage({ userName, userEmail }: DashboardPageProps) { + const bindingRequired = requiresEmailBinding(); + return ( <> - + diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx index bfdd4b0..d38d4e7 100644 --- a/app/vip/components/LandingPage.tsx +++ b/app/vip/components/LandingPage.tsx @@ -13,7 +13,11 @@ import { LoginModal } from "./landing/LoginModal"; type LandingPageProps = { onJoin: () => void; onVerify: (userId: string) => Promise; - onVerifyByEmail?: (email: string, password: string) => Promise; + onVerifyByEmail?: ( + email: string, + password: string, + user_id?: string + ) => Promise; onVerifySuccess: () => void; }; diff --git a/app/vip/components/VipHeader.tsx b/app/vip/components/VipHeader.tsx index 84ba27f..dbbe56c 100644 --- a/app/vip/components/VipHeader.tsx +++ b/app/vip/components/VipHeader.tsx @@ -64,6 +64,14 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead > {getAvatarLetter(displayName)} + {isLoggedIn && ( + + VIP + + )} {open && (
diff --git a/app/vip/components/dashboard/WelcomeBlock.tsx b/app/vip/components/dashboard/WelcomeBlock.tsx index cfa8b34..459ab2d 100644 --- a/app/vip/components/dashboard/WelcomeBlock.tsx +++ b/app/vip/components/dashboard/WelcomeBlock.tsx @@ -1,88 +1,279 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Copyable } from "../Copyable"; import styles from "../vip.module.css"; type WelcomeBlockProps = { userName: string | null; + userEmail?: string | null; + bindingRequired?: boolean; }; +const DEFAULT_PASSWORD = "12345678"; + function getStorage(key: string): string | null { if (typeof window === "undefined") return null; return localStorage.getItem(key); } -/** 匿名 user_id 格式:user + 数字 */ -function isAnonymousUserId(val: string | null): boolean { - return !!val && /^user\d+$/.test(val); +function isAnonymousUserId(value: string | null): value is string { + return !!value && /^user\d+$/.test(value); } -export function WelcomeBlock({ userName }: WelcomeBlockProps) { - const displayName = userName || getStorage("userName") || "会员"; +export function WelcomeBlock({ + userName, + userEmail, + bindingRequired = false, +}: WelcomeBlockProps) { const userId = getStorage("userId"); - const needLinkEmail = isAnonymousUserId(userId); + const storedUserName = getStorage("userName"); + const storedUserEmail = getStorage("userEmail"); + const emailLinked = getStorage("emailLinked"); + const [dbEmail, setDbEmail] = useState(null); + const [backendLinkedEmail, setBackendLinkedEmail] = useState(null); + const [backendMemosAccount, setBackendMemosAccount] = useState(null); const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [passwordConfirm, setPasswordConfirm] = useState(""); + const [checkingLink, setCheckingLink] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [linked, setLinked] = useState(false); - const handleLinkSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setLoading(true); + const displayName = + storedUserEmail || userEmail || userName || storedUserName || "会员"; + + const storedLinkedEmail = + storedUserEmail || (storedUserName?.includes("@") ? storedUserName : null); + const memosAccount = + getStorage("memosAccount") || + backendMemosAccount || + (isAnonymousUserId(userId) ? userId : null); + + const pbEmail = (() => { try { - if (password !== passwordConfirm) { - setError("两次密码不一致"); - setLoading(false); - return; - } - if (password.length < 8) { - setError("密码至少 8 位"); - setLoading(false); - return; + const raw = getStorage("pb_user"); + if (!raw) return null; + const parsed = JSON.parse(raw) as { email?: string }; + return parsed.email ?? null; + } catch { + return null; + } + })(); + + const alreadyLinked = + emailLinked === "1" || + !!storedLinkedEmail || + !!backendLinkedEmail || + !!pbEmail; + + const needLinkEmail = + bindingRequired && + isAnonymousUserId(userId) && + !alreadyLinked && + !checkingLink; + + useEffect(() => { + if (!userId || (alreadyLinked && memosAccount)) { + setCheckingLink(false); + return; + } + + fetch(`/api/vip/linked-email?user_id=${encodeURIComponent(userId)}`, { + cache: "no-store", + }) + .then((res) => res.json()) + .then((data) => { + const linkedEmail = data?.email; + const linkedAnonymousUserId = + typeof data?.anonymousUserId === "string" && + /^user\d+$/.test(data.anonymousUserId) + ? data.anonymousUserId + : null; + if (linkedAnonymousUserId && typeof window !== "undefined") { + setBackendMemosAccount(linkedAnonymousUserId); + localStorage.setItem("memosAccount", linkedAnonymousUserId); + } + if (!linkedEmail || typeof window === "undefined") return; + + setBackendLinkedEmail(linkedEmail); + localStorage.setItem("emailLinked", "1"); + localStorage.setItem("userEmail", linkedEmail); + localStorage.setItem("userName", linkedEmail); + + if (bindingRequired) { + window.location.reload(); + } + }) + .catch(() => {}) + .finally(() => setCheckingLink(false)); + }, [alreadyLinked, bindingRequired, memosAccount, userId]); + + useEffect(() => { + if (storedLinkedEmail || pbEmail || backendLinkedEmail) return; + if (userEmail) { + setDbEmail(userEmail); + return; + } + + const applyEmail = (nextEmail: string | null) => { + if (!nextEmail) return; + setDbEmail(nextEmail); + if (typeof window !== "undefined" && emailLinked === "1") { + localStorage.setItem("userEmail", nextEmail); } + }; + + fetch("/api/auth/email", { credentials: "include", cache: "no-store" }) + .then((res) => res.json()) + .then((data) => { + if (data?.email) { + applyEmail(data.email); + return; + } + + return fetch("/api/auth/me", { + credentials: "include", + cache: "no-store", + }) + .then((res) => res.json()) + .then((meData) => applyEmail(meData?.user?.email ?? null)); + }) + .catch(() => {}); + }, [backendLinkedEmail, emailLinked, pbEmail, storedLinkedEmail, userEmail]); + + const handleLinkSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!userId || !isAnonymousUserId(userId)) return; + + setLoading(true); + setError(null); + + try { + const normalizedEmail = email.trim().toLowerCase(); const res = await fetch("/api/auth/link-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - email: email.trim(), - password, + email: normalizedEmail, + password: DEFAULT_PASSWORD, anonymousUserId: userId, }), }); - const data = await res.json(); - if (!data?.ok) { - setError(data?.error || "操作失败"); - setLoading(false); + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data?.ok || !data?.token || !data?.record?.id) { + setError(data?.error || "绑定邮箱失败"); return; } + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); - pbSaveAuth(data.token, { id: data.record.id, email: data.record.email }); + pbSaveAuth(data.token, { + id: data.record.id, + email: data.record.email, + }); + await fetch("/api/auth/sync-session", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: data.token, record: data.record }), + body: JSON.stringify({ + token: data.token, + record: data.record, + anonymousUserId: userId, + }), credentials: "include", }); - if (typeof window !== "undefined") { - localStorage.setItem("userId", data.record.id); - localStorage.setItem("userName", data.record.email || data.record.id); - } - setLinked(true); - window.location.reload(); + + localStorage.setItem("memosAccount", userId); + localStorage.setItem("userId", data.record.id); + localStorage.setItem("userEmail", data.record.email || normalizedEmail); + localStorage.setItem("userName", data.record.email || normalizedEmail); + localStorage.setItem("emailLinked", "1"); + localStorage.setItem("paidType", "vip"); + + window.location.href = "/"; } catch { - setError("操作失败"); + setError("绑定邮箱失败"); } finally { setLoading(false); } }; - if (needLinkEmail && !linked) { - return ( + const displayEmail = + storedLinkedEmail || pbEmail || backendLinkedEmail || dbEmail || userEmail || null; + + return ( + <> + {needLinkEmail ? ( +
+
+
+ 先绑定邮箱,再进入 VIP +
+

+ 当前会员还在匿名账号下。绑定后会迁移到统一邮箱账号,默认密码为 + {" "} + {DEFAULT_PASSWORD} + 。 +

+
+ setEmail(event.target.value)} + placeholder="your@email.com" + required + disabled={loading} + style={{ + width: "100%", + padding: "0.75rem 1rem", + fontSize: "1rem", + border: "1px solid var(--border)", + borderRadius: "8px", + background: "var(--background)", + color: "var(--foreground)", + marginBottom: "0.75rem", + }} + /> + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+ ) : null} +

- 欢迎加入,请补充邮箱 + 欢迎回来,{displayName}

- 补充邮箱后可在各站点通用登录,Memos 账号密码为 12345678 + 以下是当前会员账号和登录信息

-
- setEmail(e.target.value)} - placeholder="your@email.com" - required - disabled={loading} - style={{ - width: "100%", - padding: "0.75rem 1rem", - fontSize: "1rem", - border: "1px solid var(--border)", - borderRadius: "8px", - background: "var(--background)", - color: "var(--foreground)", - marginBottom: "0.75rem", - }} - /> - setPassword(e.target.value)} - placeholder="至少 8 位" - required - minLength={8} - disabled={loading} - style={{ - width: "100%", - padding: "0.75rem 1rem", - fontSize: "1rem", - border: "1px solid var(--border)", - borderRadius: "8px", - background: "var(--background)", - color: "var(--foreground)", - marginBottom: "0.75rem", - }} - /> - setPasswordConfirm(e.target.value)} - placeholder="再次输入密码" - required - minLength={8} - disabled={loading} - style={{ - width: "100%", - padding: "0.75rem 1rem", - fontSize: "1rem", - border: "1px solid var(--border)", - borderRadius: "8px", - background: "var(--background)", - color: "var(--foreground)", - marginBottom: "0.75rem", - }} - /> - {error && ( -

{error}

- )} - -
+
+
会员登录信息
+
+ 网址 + + qun.hackrobot.cn + + + 打开 + +
+
+ 账号 + + {memosAccount || "-"} + +
+
+ 密码 + + {DEFAULT_PASSWORD} + +
+
+ 邮箱 + + {displayEmail || "-"} + +
+
- ); - } - - return ( -
-

- 欢迎回来,{displayName} -

-

- 以下是您的会员资产与推荐动作 -

-
-
星球登录信息
-
- 网址 - qun.hackrobot.cn - 打开 -
-
- 账号 - {displayName} -
-
- 密码 - 12345678 -
-
-
+ ); } diff --git a/app/vip/components/landing/LoginForm.tsx b/app/vip/components/landing/LoginForm.tsx index 1bc61fa..279290f 100644 --- a/app/vip/components/landing/LoginForm.tsx +++ b/app/vip/components/landing/LoginForm.tsx @@ -1,16 +1,25 @@ "use client"; -import { useState, useRef, useEffect, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import styles from "../vip.module.css"; type LoginFormProps = { onVerify: (userId: string) => Promise; - onVerifyByEmail?: (email: string, password: string) => Promise; + onVerifyByEmail?: ( + email: string, + password: string, + user_id?: string + ) => Promise; onSuccess: () => void; autoFocus?: boolean; }; -export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: LoginFormProps) { +export function LoginForm({ + onVerify, + onVerifyByEmail, + onSuccess, + autoFocus, +}: LoginFormProps) { const [mode, setMode] = useState<"user_id" | "email">("user_id"); const [userId, setUserId] = useState(""); const [email, setEmail] = useState(""); @@ -30,6 +39,7 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L setError("请输入您的账号"); return; } + setError(null); setLoading(true); try { @@ -46,17 +56,26 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L const handleEmailSubmit = async (e: FormEvent) => { e.preventDefault(); if (!onVerifyByEmail) return; + const trimmedEmail = email.trim(); - if (!trimmedEmail || !password) { - setError("请输入邮箱和密码"); + const trimmedPassword = password.trim(); + if (!trimmedEmail) { + setError("请输入邮箱"); return; } + if (!trimmedPassword) { + setError("请输入密码"); + return; + } + setError(null); setLoading(true); try { - const ok = await onVerifyByEmail(trimmedEmail, password); + const result = await onVerifyByEmail(trimmedEmail, trimmedPassword); + const ok = typeof result === "object" ? result.ok : result; + const errMsg = typeof result === "object" ? result.error : undefined; if (ok) onSuccess(); - else setError("未找到该邮箱关联的会员记录,请确认账号正确"); + else setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录"); } catch { setError("验证失败,请稍后重试"); } finally { @@ -80,19 +99,34 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L
+ {mode === "user_id" ? (
setUserId(e.target.value)} - placeholder="例如:user1234567890" + placeholder="例如:user1773315608560" disabled={loading} style={inputStyle} /> - {error &&

{error}

} -
@@ -124,20 +167,48 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="密码" + placeholder="请输入密码" disabled={loading} style={inputStyle} /> - {error &&

{error}

} - ) : (
- setUserId(e.target.value)} placeholder="例如:user1234567890" disabled={loading} style={inputStyle} /> - {error &&

{error}

} - + setUserId(e.target.value)} + placeholder="例如:user1773315608560" + disabled={loading} + style={inputStyle} + /> + {error && ( +

+ {error} +

+ )} +
)} diff --git a/app/vip/components/landing/LoginModal.tsx b/app/vip/components/landing/LoginModal.tsx index 22924dd..0427763 100644 --- a/app/vip/components/landing/LoginModal.tsx +++ b/app/vip/components/landing/LoginModal.tsx @@ -8,7 +8,11 @@ type LoginModalProps = { open: boolean; onClose: () => void; onVerify: (userId: string) => Promise; - onVerifyByEmail?: (email: string, password: string) => Promise; + onVerifyByEmail?: ( + email: string, + password: string, + user_id?: string + ) => Promise; onSuccess: () => void; }; diff --git a/app/vip/components/vip.module.css b/app/vip/components/vip.module.css index 6463c5b..14267e1 100644 --- a/app/vip/components/vip.module.css +++ b/app/vip/components/vip.module.css @@ -54,6 +54,20 @@ font-weight: 500; } +.vipTag { + position: absolute; + right: -4px; + top: -2px; + font-size: 0.6rem; + font-weight: 700; + padding: 0.1rem 0.35rem; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + letter-spacing: 0.02em; +} + .main { max-width: 1200px; margin: 0 auto; diff --git a/docs/AUTH_VIP_CHECK_REPORT.md b/docs/AUTH_VIP_CHECK_REPORT.md index 34734e3..37b4927 100644 --- a/docs/AUTH_VIP_CHECK_REPORT.md +++ b/docs/AUTH_VIP_CHECK_REPORT.md @@ -69,8 +69,11 @@ payjsapi 不在本仓库,需在 api.hackrobot.cn 侧确认: 支付成功后,payjsapi 需: 1. 从订单中读取 `user_id`、`site_id` -2. 调用 PocketBase 或内部 API 写入/更新 `site_vip` 表 -3. 字段:user_id、site_id、order_id、expires_at(可选) +2. **写入 PocketBase `payments` 表**(user_id、type、order_id 等) +3. **写入 PocketBase `site_vip` 表**(user_id、site_id、order_id、expires_at) +4. **创建 Memos 账号**(qun.hackrobot.cn,账号名=user_id,密码 12345678) + +详见 `docs/PAYJSAPI_NOMADVIP_SPEC.md`。 ### 3.3 PocketBase site_vip 表 @@ -96,3 +99,5 @@ payjsapi 不在本仓库,需在 api.hackrobot.cn 侧确认: - [x] digital/cnomadcna auth/me 使用 credentials - [ ] payjsapi 确认 nomadvip 订单接收并处理 site_id - [ ] payjsapi 确认支付回调写入 site_vip +- [ ] payjsapi 确认支付回调写入 payments 表 +- [ ] payjsapi 确认支付回调创建 Memos 账号(user_id 格式,密码 12345678) diff --git a/docs/PAYJSAPI_NOMADVIP_SPEC.md b/docs/PAYJSAPI_NOMADVIP_SPEC.md new file mode 100644 index 0000000..ee2fe0d --- /dev/null +++ b/docs/PAYJSAPI_NOMADVIP_SPEC.md @@ -0,0 +1,219 @@ +# payjsapi nomadvip 对接规范 + +**问题**:支付成功后,页面显示账号,但 PocketBase `payments` 表和 Memos 账户均无记录,导致会员验证失败。 + +**原因**:payjsapi 支付回调未正确写入 PocketBase 及创建 Memos 账号。 + +--- + +## 一、nomadvip 调用流程 + +``` +用户点击「立即加入」 + → nomadvip POST /api/pay (user_id, site_id: "vip", ...) + → nomadvip 调用 payjsapi POST /nomadvip/payh5 + → payjsapi 返回支付参数,用户完成支付 + → 支付渠道回调 payjsapi + → 【payjsapi 必须】写入 PocketBase payments + site_vip,创建 Memos 账号 + → nomadvip 轮询 GET /api/pay/status?order_id=xxx + → nomadvip 调用 payjsapi GET /nomadvip/order_status?order_id=xxx + → payjsapi 返回 { paid: true } +``` + +--- + +## 二、payjsapi 支付回调必须完成 + +### 2.1 写入 PocketBase `payments` 表 + +| 字段 | 说明 | 示例 | +|------|------|------| +| user_id | 订单中的 user_id(匿名格式 `user1773315608560` 或 PB 用户 ID) | `user1773315608560` | +| type | 产品类型 | `vip` | +| order_id | 支付订单号 | 渠道返回的 out_trade_no | +| total_fee | 金额(分) | 8800 | +| channel | 支付渠道 | wxpay / alipay | +| created | 创建时间 | 自动 | + +**nomadvip 依赖**:`app/page.tsx` 的 `checkPaymentFromPB` 按 `user_id` 查询 payments 判断是否 VIP。**不写入则会员验证失败**。 + +### 2.2 写入 PocketBase `site_vip` 表 + +| 字段 | 说明 | 示例 | +|------|------|------| +| user_id | 同 payments.user_id | `user1773315608560` | +| site_id | 固定 `vip` | `vip` | +| order_id | 支付订单号 | 可选 | +| expires_at | 到期时间,null 表示永久 | null | + +**nomadvip 依赖**:`/api/vip/check` 按 `user_id` + `site_id = "vip"` 校验 Cookie 登录用户的 VIP 状态。 + +### 2.3 创建 Memos 账号 + +- **Memos 地址**:qun.hackrobot.cn +- **账号格式**:`user` + 时间戳,如 `user1773315608560` +- **默认密码**:`12345678` + +payjsapi 在支付成功后,需调用 Memos API 或内部服务创建账号,账号名与订单中的 `user_id` 一致(匿名用户即为 `user1773315608560` 格式)。 + +**nomadvip 依赖**:首页「星球登录信息」展示的账号即为此格式,用户需用该账号登录 qun.hackrobot.cn。 + +--- + +## 三、payjsapi 接口要求 + +### 3.1 订单创建 `POST /nomadvip/payh5` + +**请求体**(nomadvip 已传递): + +```json +{ + "user_id": "user1773315608560", + "site_id": "vip", + "total_fee": 8800, + "type": "vip", + "channel": "wxpay", + "return_url": "https://vip.hackrobot.cn/paid", + "client_ip": "可选" +} +``` + +**要求**:订单存储时需保存 `user_id`、`site_id`,供支付回调使用。 + +### 3.2 订单状态 `GET /nomadvip/order_status?order_id=xxx` + +**响应**: + +```json +{ "paid": true } +``` + +或 + +```json +{ "paid": false } +``` + +**要求**:支付回调完成写入 payments/site_vip 后,此接口应返回 `paid: true`。nomadvip 据此判断支付成功并刷新页面。 + +--- + +## 四、order_status 接口(关键) + +**nomadvip 依赖**:支付成功后,nomadvip 轮询 `order_status`,若返回 `paid: true` 则调用 `/api/pay/confirm` 写入 PocketBase 和 Memos。**若 order_status 返回 false 或接口不存在,则 confirm 失败,payments/site_vip/Memos 均无记录**。 + +payjsapi 必须实现: + +``` +GET /nomadvip/order_status?order_id=xxx +响应: { "paid": true } 或 { "paid": false } +``` + +- 参数名必须为 `order_id`(与 nomadvip 传递一致) +- 支付渠道回调完成后,必须返回 `paid: true` +- 若 payh5/redirect 返回 301-308 重定向,建议响应头带上 `x-pay-order-id`,以便 nomadvip 正确轮询 + +## 五、已有账号登录(可选,优先于 PocketBase 查询) + +nomadvip 会优先调用 payjsapi 验证 VIP,若接口不存在或失败则回退到 PocketBase。 + +### 5.1 按 user_id 查 VIP `GET /nomadvip/check_vip?user_id=xxx` + +**响应**: + +```json +{ "vip": true, "email": "user@example.com" } +``` + +或 + +```json +{ "vip": false } +``` + +**说明**:payjsapi 需查 users/payments/site_vip 关联,返回是否 VIP;若已关联邮箱可返回 `email` 供前端不再显示绑定表单。 + +### 5.2 按邮箱+密码查 VIP `POST /nomadvip/check_vip_by_email` + +**请求体**: + +```json +{ "email": "user@example.com", "password": "xxx" } +``` + +**响应**: + +```json +{ + "vip": true, + "token": "PB token", + "record": { "id": "xxx", "email": "xxx" }, + "effectiveUserId": "user1773315608560" +} +``` + +或 + +```json +{ "vip": false } +``` + +**说明**:payjsapi 需验证邮箱密码(PB users),再查 payments/site_vip 关联,返回 VIP 状态及 PB token/record。 + +--- + +## 六、检查清单(payjsapi 侧) + +- [ ] 支付回调写入 PocketBase `payments` 表 +- [ ] 支付回调写入 PocketBase `site_vip` 表(site_id = "vip") +- [ ] 支付回调创建 Memos 账号(user_id 格式,密码 12345678) +- [ ] **`order_status` 在支付成功后返回 `paid: true`**(否则 nomadvip confirm 失败) +- [ ] 订单创建时正确保存 `user_id`、`site_id` +- [ ] (可选)`GET /nomadvip/check_vip?user_id=xxx` 按 user_id 查 VIP +- [ ] (可选)`POST /nomadvip/check_vip_by_email` 按邮箱密码查 VIP + +--- + +## 七、PocketBase 配置 + +payjsapi 需配置: + +- `POCKETBASE_URL`:PocketBase 地址(如 https://pocketbase.hackrobot.cn) +- `POCKETBASE_EMAIL` / `POCKETBASE_PASSWORD`:管理员账号,用于写入 payments、site_vip + +--- + +## 八、日志要求(排查 VIP 登录问题) + +payjsapi 需在以下接口打印日志,便于排查「已有账号登录」失败: + +| 接口 | 建议日志 | +|------|----------| +| `GET /nomadvip/check_vip?user_id=xxx` | 请求 user_id、查询结果 vip | +| `POST /nomadvip/check_vip_by_email` | 请求 email、PB 登录结果、vip 查询结果 | +| `GET /nomadvip/order_status?order_id=xxx` | 请求 order_id、返回 paid | +| 支付回调 | 写入 payments/site_vip 的 user_id、order_id | + +示例(Node/Express): +```js +// check_vip +console.log("[payjsapi] check_vip user_id=%s vip=%s", user_id, vip); + +// check_vip_by_email +console.log("[payjsapi] check_vip_by_email email=%s pbLogin=%s vip=%s", email, !!token, vip); + +// order_status +console.log("[payjsapi] order_status order_id=%s paid=%s", order_id, paid); +``` + +--- + +## 九、Memos 集成 + +Memos 账号创建方式取决于现有架构,可选: + +1. 直接调用 Memos API 创建用户 +2. 通过内部服务/队列异步创建 +3. 与 PocketBase users 表联动(若 Memos 与 PB 共享用户) + +账号名必须与 `user_id` 一致,以便 nomadvip 页面展示与登录流程一致。 diff --git a/docs/VIP_EMAIL_LINK_SCHEMA.md b/docs/VIP_EMAIL_LINK_SCHEMA.md new file mode 100644 index 0000000..e0001ba --- /dev/null +++ b/docs/VIP_EMAIL_LINK_SCHEMA.md @@ -0,0 +1,39 @@ +# vip_email_link 集合说明 + +「已有账号登录」按邮箱查库需要 `vip_email_link` 映射表。请在 PocketBase 管理后台创建。 + +**若出现 404**:检查集合名称是否为 `vip_email_link`(小写+下划线),且必须添加 `anonymous_user_id` 字段。 + +## 集合名称 + +`vip_email_link`(必须与此完全一致) + +## 字段(全部必建) + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| email | Text | 邮箱,建议唯一 | +| user_id | Text | 关联的 PB 用户 ID | +| anonymous_user_id | Text | **必建** 匿名 user_id(如 user1773315608560),用于已有账号登录查关联 | + +## 创建方式 + +1. 打开 PocketBase 管理后台 +2. Collections → New collection +3. 名称:`vip_email_link`(精确匹配) +4. 添加字段:`email` (Text)、`user_id` (Text)、`anonymous_user_id` (Text) +5. 可选:为 `email` 设置 unique 约束 +6. API Rules:Admin 需能访问,或留空使用默认 + +## 数据来源 + +- **支付成功页**:用户输入邮箱后调用 `POST /api/auth/store-email-link` 写入 `email -> 匿名 user_id` +- **关联邮箱**:`POST /api/auth/link-email` 成功后写入 `email -> PB user_id`(覆盖) + +## 查询流程 + +`POST /api/vip/check-by-email` 会: + +1. 验证邮箱密码(PB 登录) +2. 按 PB id 查 site_vip、payments +3. 若未找到,按 email 查 vip_email_link 得 user_id,再查 site_vip、payments