This commit is contained in:
eric
2026-03-13 03:43:50 -05:00
parent e5d080c202
commit 857380aa01
29 changed files with 3595 additions and 580 deletions

View File

@@ -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 });
}
}

View File

@@ -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<string | null> {
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<void> {
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<void> {
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 }
);
}
}

View File

@@ -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<string, string | number | boolean>);
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;
}

View File

@@ -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<string, string | number | boolean>);
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<string, string> = {
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<string, string | number | boolean>);
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 {

View File

@@ -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<string | null> {
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<void> {
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<string, string> = { 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 }
);
}
}

View File

@@ -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<string, string> = {
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<string, string | number | boolean>);
res.cookies.set(
COOKIE_NAME,
encoded,
getCookieOptions(false, getHostFromRequest(request)) as Record<
string,
string | number | boolean
>
);
return res;
}

View File

@@ -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<string | null> {
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<boolean> {
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<boolean> {
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 }
);
}
}

View File

@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const returnUrl = String(resData.return_url || finalReturnUrl).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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);
})();
</script>
</body></html>`;
@@ -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}`;

View File

@@ -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 });
}

510
app/api/vip/_helpers.ts Normal file
View File

@@ -0,0 +1,510 @@
import { SITE_ID, getPocketBaseConfig } from "@/config/services.config";
const VIP_EMAIL_LINK_COLLECTION = "vip_email_link";
type PBRecord = Record<string, unknown>;
function getPBBaseUrl(): string {
return getPocketBaseConfig().url.replace(/\/$/, "");
}
function escapeFilterValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function uniqueStrings(values: Array<string | null | undefined>): 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<PBRecord[]> {
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<string, unknown>
): Promise<void> {
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<string, unknown>
): Promise<void> {
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<void> {
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<PBRecord | null> {
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<string | null> {
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<PBRecord[]> {
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<PBRecord[]> {
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<PBRecord[]> {
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<PBRecord | null> {
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<boolean> {
return !!(await getActiveSiteVipRecord(adminToken, userId));
}
export async function getLatestVipPayment(
adminToken: string,
userId: string
): Promise<PBRecord | null> {
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<boolean> {
return !!(await getLatestVipPayment(adminToken, userId));
}
export async function hasVipByUserId(
adminToken: string,
userId: string
): Promise<boolean> {
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<void> {
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<string, unknown> = { user_id: toUserId, site_id: SITE_ID };
if (nextOrderId) {
body.order_id = nextOrderId;
}
await patchRecord("site_vip", canonicalRecordId, adminToken, body);
const duplicateIds = new Set<string>();
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<void> {
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<string, unknown> = {
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<PBRecord | null> {
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<PBRecord | null> {
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<PBRecord | null> {
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<string | null> {
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<string>();
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<string | null> {
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;
}

View File

@@ -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<PayjsVipEmailResult | null> {
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<RecoveryResult> {
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 }
);
}
}

View File

@@ -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<PayjsVipResult | null> {
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 });
}
}

View File

@@ -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<string | null> {
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 });
}

View File

@@ -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<string | null> {
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 });
}
}