's'
This commit is contained in:
19
app/api/auth/email/route.ts
Normal file
19
app/api/auth/email/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
125
app/api/auth/store-email-link/route.ts
Normal file
125
app/api/auth/store-email-link/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
253
app/api/pay/confirm/route.ts
Normal file
253
app/api/pay/confirm/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
</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}`;
|
||||
|
||||
@@ -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
510
app/api/vip/_helpers.ts
Normal 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;
|
||||
}
|
||||
329
app/api/vip/check-by-email/route.ts
Normal file
329
app/api/vip/check-by-email/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
205
app/api/vip/check-by-user/route.ts
Normal file
205
app/api/vip/check-by-user/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
117
app/api/vip/linked-email/route.ts
Normal file
117
app/api/vip/linked-email/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
67
app/lib/xorpayStatus.ts
Normal file
67
app/lib/xorpayStatus.ts
Normal file
@@ -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<XorPayStatusResult | null> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
59
app/lib/zpayStatus.ts
Normal file
59
app/lib/zpayStatus.ts
Normal file
@@ -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<ZPayStatusResult | null> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
535
app/page.tsx
535
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<string | null> {
|
||||
}, onPendingOrder?: (orderId: string) => void): Promise<string | null> {
|
||||
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<string, string>)) {
|
||||
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<string, string>)) {
|
||||
@@ -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<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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() {
|
||||
<>
|
||||
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
|
||||
{isLoggedIn ? (
|
||||
<DashboardPage userName={userName} />
|
||||
<DashboardPage userName={userName} userEmail={userEmail} />
|
||||
) : (
|
||||
<>
|
||||
<LandingPage
|
||||
@@ -474,11 +800,6 @@ export default function Home() {
|
||||
onVerifyByEmail={checkVerifyByEmail}
|
||||
onVerifySuccess={handleVerifySuccess}
|
||||
/>
|
||||
<PayAuthModal
|
||||
open={payAuthModalOpen}
|
||||
onClose={() => setPayAuthModalOpen(false)}
|
||||
onSuccess={handlePayAuthSuccess}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VipLayout>
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string | null>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg" style={{ animationFillMode: "both" }}>
|
||||
<div
|
||||
className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
|
||||
✅
|
||||
✓
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付成功</h2>
|
||||
<p className="mt-3 text-slate-500">感谢您的支持,VIP 权益已开通</p>
|
||||
<p className="mt-2 text-xs text-slate-400">2 秒后自动返回首页</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => { 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"
|
||||
>
|
||||
← 立即返回首页
|
||||
</a>
|
||||
<p className="mt-3 text-slate-500">
|
||||
最后一步,绑定邮箱后再进入 VIP。后续可以在多个站点共用同一账号。
|
||||
</p>
|
||||
{isAnonymousUserId(anonymousUserId) ? (
|
||||
<form onSubmit={handleSubmit} className="mt-6 text-left">
|
||||
<p className="mb-2 text-sm text-slate-600">
|
||||
默认密码为 {DEFAULT_PASSWORD}。绑定后会把当前匿名会员迁移到统一邮箱账号。
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => 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 ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-sky-500 px-6 py-2 font-semibold text-white hover:bg-sky-600 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "处理中..." : "绑定邮箱并进入 VIP"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-2 text-xs text-slate-400">正在返回首页</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(event) => {
|
||||
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"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaidPage() {
|
||||
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
|
||||
const [fromUrl, setFromUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"pending" | "success" | "fail" | "confirm_fail"
|
||||
>("pending");
|
||||
const [confirmedUserId, setConfirmedUserId] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="animate__animated animate__fadeInUp w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg" style={{ animationFillMode: "both" }}>
|
||||
<div
|
||||
className="animate__animated animate__fadeInUp w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 animate-spin items-center justify-center rounded-full border-4 border-sky-200 border-t-sky-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800">支付结果确认中</h2>
|
||||
<h2 className="text-xl font-bold text-slate-800">正在确认支付结果</h2>
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
正在查询支付状态,请稍候…
|
||||
正在写入会员记录,请稍候。
|
||||
<br />
|
||||
(支付完成后将自动跳转,1 分钟内未支付将返回首页)
|
||||
支付完成后会继续进入邮箱绑定。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,25 +272,63 @@ export default function PaidPage() {
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
const anonymousUserId =
|
||||
confirmedUserId ||
|
||||
(typeof window !== "undefined" ? localStorage.getItem("userId") : null);
|
||||
return <SuccessPanel anonymousUserId={anonymousUserId} />;
|
||||
}
|
||||
|
||||
if (status === "confirm_fail") {
|
||||
return (
|
||||
<SuccessWithRedirect />
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div
|
||||
className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-amber-50 text-5xl">
|
||||
!
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付已确认,权益开通中</h2>
|
||||
<p className="mt-3 text-slate-500">
|
||||
订单已支付,但会员写入可能有延迟。请稍后刷新首页,或联系管理员处理。
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(event) => {
|
||||
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"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg" style={{ animationFillMode: "both" }}>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-amber-50 text-5xl">
|
||||
⚠️
|
||||
<div
|
||||
className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-rose-50 text-5xl">
|
||||
!
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付未完成</h2>
|
||||
<p className="mt-3 text-slate-500">支付已取消或未成功,请重试</p>
|
||||
<p className="mt-3 text-slate-500">
|
||||
当前没有确认到成功支付记录。你可以返回首页重新发起支付。
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => { e.preventDefault(); window.location.href = "/"; }}
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full border-2 border-slate-300 px-8 py-3 font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:bg-slate-50"
|
||||
onClick={(event) => {
|
||||
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"
|
||||
>
|
||||
← 返回首页
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<WelcomeBlock userName={userName} />
|
||||
<WelcomeBlock
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
bindingRequired={bindingRequired}
|
||||
/>
|
||||
<UnlockedStats />
|
||||
<TopicEbookShowcase />
|
||||
</>
|
||||
|
||||
@@ -13,7 +13,11 @@ import { LoginModal } from "./landing/LoginModal";
|
||||
type LandingPageProps = {
|
||||
onJoin: () => void;
|
||||
onVerify: (userId: string) => Promise<boolean>;
|
||||
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
|
||||
onVerifyByEmail?: (
|
||||
email: string,
|
||||
password: string,
|
||||
user_id?: string
|
||||
) => Promise<boolean | { ok: boolean; error?: string }>;
|
||||
onVerifySuccess: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -64,6 +64,14 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead
|
||||
>
|
||||
{getAvatarLetter(displayName)}
|
||||
</button>
|
||||
{isLoggedIn && (
|
||||
<span
|
||||
className={styles.vipTag}
|
||||
title="VIP 会员"
|
||||
>
|
||||
VIP
|
||||
</span>
|
||||
)}
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<span className="block px-4 py-2 text-sm text-slate-600 dark:text-slate-400 truncate max-w-[160px]">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [backendLinkedEmail, setBackendLinkedEmail] = useState<string | null>(null);
|
||||
const [backendMemosAccount, setBackendMemosAccount] = useState<string | null>(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<string | null>(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<HTMLFormElement>) => {
|
||||
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 ? (
|
||||
<section
|
||||
className={styles.section}
|
||||
style={{ paddingTop: "1.5rem", paddingBottom: "0.5rem" }}
|
||||
>
|
||||
<div className={styles.loginCard} style={{ maxWidth: "420px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.35rem",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
先绑定邮箱,再进入 VIP
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--muted-foreground)",
|
||||
marginBottom: "0.9rem",
|
||||
}}
|
||||
>
|
||||
当前会员还在匿名账号下。绑定后会迁移到统一邮箱账号,默认密码为
|
||||
{" "}
|
||||
{DEFAULT_PASSWORD}
|
||||
。
|
||||
</p>
|
||||
<form onSubmit={handleLinkSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => 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 ? (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "#ef4444",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "处理中..." : "绑定邮箱并继续"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
||||
<h1
|
||||
className="animate__animated animate__fadeInDown"
|
||||
@@ -94,135 +285,56 @@ export function WelcomeBlock({ userName }: WelcomeBlockProps) {
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
欢迎加入,请补充邮箱
|
||||
欢迎回来,<Copyable text={displayName}>{displayName}</Copyable>
|
||||
</h1>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
color: "var(--muted-foreground)",
|
||||
margin: "0.25rem 0 1rem",
|
||||
margin: "0.25rem 0 0",
|
||||
animationDelay: "0.15s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
补充邮箱后可在各站点通用登录,Memos 账号密码为 12345678
|
||||
以下是当前会员账号和登录信息
|
||||
</p>
|
||||
<form onSubmit={handleLinkSubmit} className={styles.loginCard} style={{ maxWidth: "400px" }}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => 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 && (
|
||||
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "处理中…" : "确认并关联"}
|
||||
</button>
|
||||
</form>
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.loginCardTitle}>会员登录信息</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>网址</span>
|
||||
<Copyable text="https://qun.hackrobot.cn">
|
||||
qun.hackrobot.cn
|
||||
</Copyable>
|
||||
<a
|
||||
href="https://qun.hackrobot.cn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.loginCardLink}
|
||||
>
|
||||
打开
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>账号</span>
|
||||
<Copyable text={memosAccount || ""}>
|
||||
<span className={styles.loginCardValue}>{memosAccount || "-"}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>密码</span>
|
||||
<Copyable text={DEFAULT_PASSWORD}>
|
||||
<span className={styles.loginCardValue}>{DEFAULT_PASSWORD}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>邮箱</span>
|
||||
<Copyable text={displayEmail || ""}>
|
||||
<span className={styles.loginCardValue}>{displayEmail || "-"}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} style={{ paddingTop: "1.5rem" }}>
|
||||
<h1
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "clamp(1.5rem, 4vw, 2rem)",
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
animationDelay: "0.1s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
欢迎回来,<Copyable text={displayName}>{displayName}</Copyable>
|
||||
</h1>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
color: "var(--muted-foreground)",
|
||||
margin: "0.25rem 0 0",
|
||||
animationDelay: "0.15s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
以下是您的会员资产与推荐动作
|
||||
</p>
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.loginCardTitle}>星球登录信息</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>网址</span>
|
||||
<Copyable text="https://qun.hackrobot.cn">qun.hackrobot.cn</Copyable>
|
||||
<a href="https://qun.hackrobot.cn" target="_blank" rel="noopener noreferrer" className={styles.loginCardLink}>打开</a>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>账号</span>
|
||||
<Copyable text={displayName}><span className={styles.loginCardValue}>{displayName}</span></Copyable>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>密码</span>
|
||||
<Copyable text="12345678"><span className={styles.loginCardValue}>12345678</span></Copyable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<boolean>;
|
||||
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
|
||||
onVerifyByEmail?: (
|
||||
email: string,
|
||||
password: string,
|
||||
user_id?: string
|
||||
) => Promise<boolean | { ok: boolean; error?: string }>;
|
||||
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
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode("user_id"); setError(null); }}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "user_id" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
|
||||
onClick={() => {
|
||||
setMode("user_id");
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium ${
|
||||
mode === "user_id"
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
账号
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode("email"); setError(null); }}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "email" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
|
||||
onClick={() => {
|
||||
setMode("email");
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium ${
|
||||
mode === "email"
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
邮箱
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "user_id" ? (
|
||||
<form onSubmit={handleUserIdSubmit}>
|
||||
<input
|
||||
@@ -100,12 +134,21 @@ export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: L
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="例如:user1234567890"
|
||||
placeholder="例如:user1773315608560"
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>
|
||||
{error && (
|
||||
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "验证中..." : "验证并登录"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -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 && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>
|
||||
{error && (
|
||||
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "验证中..." : "验证并登录"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleUserIdSubmit}>
|
||||
<input ref={inputRef} type="text" value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="例如:user1234567890" disabled={loading} style={inputStyle} />
|
||||
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>{loading ? "验证中..." : "验证并登录"}</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="例如:user1773315608560"
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{error && (
|
||||
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "验证中..." : "验证并登录"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,11 @@ type LoginModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onVerify: (userId: string) => Promise<boolean>;
|
||||
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
|
||||
onVerifyByEmail?: (
|
||||
email: string,
|
||||
password: string,
|
||||
user_id?: string
|
||||
) => Promise<boolean | { ok: boolean; error?: string }>;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user