3 Commits
dev ... xdev

Author SHA1 Message Date
eric
8a095f7d45 'c1 2026-03-13 05:14:28 -05:00
eric
f66859c032 's' 2026-03-12 19:54:58 -05:00
eric
14bbd3b306 's' 2026-03-12 05:40:38 -05:00
35 changed files with 2057 additions and 434 deletions

7
.gitignore vendored
View File

@@ -39,3 +39,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.next
.next_stale*
node_modules
dist
build

View File

@@ -4,13 +4,11 @@ import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "r
import { Link } from "@/i18n/navigation";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import type { PayEnv } from "@/app/lib/payment";
import AuthModal from "@/app/components/AuthModal";
const genderOptions = ["男", "女"];
@@ -52,27 +50,51 @@ export default function JoinPage() {
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [submitted, setSubmitted] = useState(false);
const [isVipSubmit, setIsVipSubmit] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
const [authOpen, setAuthOpen] = useState(false);
const [pendingSubmit, setPendingSubmit] = useState(false);
const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false);
const [checkingStatus, setCheckingStatus] = useState(true);
useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
setSubmitted(true);
setCheckingStatus(false);
return;
}
const checkExisting = async () => {
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meData = await meRes.json();
if (!meData?.user) {
setCheckingStatus(false);
return;
}
const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" });
const statusData = await statusRes.json();
if (statusData?.hasRecord && statusData?.isComplete) {
setIsAlreadyRecorded(true);
setSubmitted(true);
}
setCheckingStatus(false);
};
checkExisting();
}, []);
usePayStatusPoll(() => setSubmitted(true));
// 轮询到支付成功时跳转到 /join/paid避免在 /join 重复显示成功页
usePayStatusPoll(() => {
if (typeof window === "undefined") return;
const path = window.location.pathname;
const paidPath = path.replace(/\/join\/?$/, "") + "/join/paid";
window.location.replace(paidPath);
});
useEffect(() => {
if (typeof window === "undefined") return;
const env = getPayEnv();
setPayEnv(env);
setPayChannel(getRecommendedChannel(env));
setPayEnv(getPayEnv());
}, []);
const set = (key: keyof FormData, value: string) =>
@@ -97,11 +119,20 @@ export default function JoinPage() {
setSubmitted(true);
return;
}
// 再次确认 VIP防止 handleAuthSuccess 等路径绕过
const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData2 = await vipRes2.json();
if (vipData2?.vip) {
setIsVipSubmit(true);
setSubmitted(true);
setSubmitting(false);
return;
}
setSubmitting(false);
setPayRedirecting(true);
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
const channel = "wxpay";
const device = getDeviceFromEnv(env);
redirectToPay({
user_id: userId,
@@ -127,6 +158,11 @@ export default function JoinPage() {
setAuthOpen(true);
return;
}
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
if (vipData?.vip) {
setIsVipSubmit(true);
}
await doSubmit();
};
@@ -145,11 +181,11 @@ export default function JoinPage() {
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100"></h2>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"}
</h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
<br />
{isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"}
</p>
<Link
href="/"
@@ -162,6 +198,14 @@ export default function JoinPage() {
);
}
if (checkingStatus) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="text-slate-500 dark:text-slate-400"></div>
</div>
);
}
return (
<div className="min-h-screen bg-[#f0f4f8] dark:bg-gray-950">
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">

View File

@@ -3,29 +3,142 @@
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
const DEFAULT_PASSWORD = "12345678";
function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
} catch {}
document.cookie = "join_pay_order=; path=/; max-age=0";
}
/**
* 支付完成回跳页
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
* 若 meetup_needs_password_change 为 true则强制弹窗修改密码
* 新用户支付成功后弹窗显示默认密码 12345678并提供修改密码选项
*/
export default function JoinPaidPage() {
const [needPasswordChange, setNeedPasswordChange] = useState<boolean | null>(null);
const [showChangeModal, setShowChangeModal] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [newPassword, setNewPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [changeError, setChangeError] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
const [showChangeForm, setShowChangeForm] = useState(false);
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
const [errorDetail, setErrorDetail] = useState<string | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
useEffect(() => {
fetch("/api/meetup/need-password-change")
.then((r) => r.json())
.then((d) => {
setNeedPasswordChange(d?.need === true);
if (d?.need === true) setShowChangeModal(true);
})
.catch(() => setNeedPasswordChange(false));
let cancelled = false;
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => {
const r = await fetch("/api/meetup/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return { ok: false, error: d?.error || `HTTP ${r.status}` };
if (d?.token && d?.record) {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
});
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(d.token, d.record);
}
window.dispatchEvent(new CustomEvent("auth:updated"));
setTimeout(() => window.dispatchEvent(new CustomEvent("auth:updated")), 500);
if (d.is_new) {
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
if (!cancelled) {
setNeedPasswordChange(true);
setShowPasswordModal(true);
}
}
return { ok: true };
};
const run = async () => {
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
const rawValue = cookie?.split("=")[1]?.trim();
const storageValue = (() => {
try {
return localStorage.getItem("join_pay_order") || "";
} catch {
return "";
}
})();
const decoded = rawValue ? decodeURIComponent(rawValue) : storageValue;
const orderId = decoded.split("|")[0]?.trim() || "";
if (!orderId) {
clearPendingOrder();
window.location.replace(`/${window.location.pathname.split("/")[1] || "zh"}/join`);
return;
}
const maxClientRetries = 5;
const retryDelays = [2000, 3000, 4000, 5000, 6000];
let lastError = "";
for (let i = 0; i <= maxClientRetries; i++) {
if (cancelled) return;
try {
const result = await tryComplete(orderId);
if (result.ok) {
clearPendingOrder();
if (!cancelled) setCompleteStatus("success");
return;
}
lastError = result.error || "unknown";
} catch (e) {
lastError = e instanceof Error ? e.message : "network error";
}
if (i < maxClientRetries) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
}
if (!cancelled) {
setCompleteStatus("error");
setErrorDetail(lastError);
}
fetch("/api/meetup/need-password-change")
.then((res) => res.json())
.then((d) => {
if (cancelled) return;
setNeedPasswordChange(d?.need === true);
if (d?.need === true) setShowPasswordModal(true);
})
.catch(() => { if (!cancelled) setNeedPasswordChange(false); });
};
run();
return () => { cancelled = true; };
}, []);
const COUNTDOWN_SECONDS = 5;
useEffect(() => {
if (completeStatus !== "success" || showPasswordModal) return;
setCountdown(COUNTDOWN_SECONDS);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev == null || prev <= 1) {
clearInterval(timer);
const locale = window.location.pathname.split("/")[1] || "zh";
window.location.replace(`/${locale}`);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [completeStatus, showPasswordModal]);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setChangeError(null);
@@ -49,7 +162,7 @@ export default function JoinPaidPage() {
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "修改失败");
}
setShowChangeModal(false);
setShowPasswordModal(false);
} catch (err) {
setChangeError(err instanceof Error ? err.message : "修改失败");
} finally {
@@ -57,6 +170,14 @@ export default function JoinPaidPage() {
}
};
const handleCopyPassword = async () => {
try {
await navigator.clipboard.writeText(DEFAULT_PASSWORD);
} catch {
/* ignore */
}
};
return (
<>
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
@@ -64,12 +185,30 @@ export default function JoinPaidPage() {
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100"></h2>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{completeStatus === "pending" ? "处理中…" : completeStatus === "success" ? "支付成功" : "处理异常"}
</h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
<br />
{completeStatus === "success"
? countdown != null
? `${countdown} 秒后返回首页`
: "感谢您的支持"
: completeStatus === "error"
? "请稍后刷新页面重试"
: "正在确认支付状态…"}
</p>
{completeStatus === "error" && errorDetail && (
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{errorDetail}</p>
)}
{completeStatus === "error" && (
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 rounded-full border border-[#ff4d4f] px-6 py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
>
</button>
)}
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
@@ -79,41 +218,79 @@ export default function JoinPaidPage() {
</div>
</div>
{showChangeModal && (
{showPasswordModal && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-xl border border-gray-100 dark:border-gray-800">
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-100"></h3>
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-100"></h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
8
</p>
<form onSubmit={handleChangePassword} className="mt-4 space-y-3">
<input
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setChangeError(null); }}
placeholder="新密码"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
<input
type="password"
value={passwordConfirm}
onChange={(e) => { setPasswordConfirm(e.target.value); setChangeError(null); }}
placeholder="确认新密码"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
{changeError && <p className="text-sm text-red-600 dark:text-red-400">{changeError}</p>}
<div className="mt-4 flex items-center gap-2 rounded-lg bg-slate-100 dark:bg-slate-800 px-4 py-3">
<code className="flex-1 font-mono text-lg font-semibold text-slate-800 dark:text-slate-100">
{DEFAULT_PASSWORD}
</code>
<button
type="submit"
disabled={changing}
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white disabled:opacity-70"
type="button"
onClick={handleCopyPassword}
className="rounded-lg px-3 py-1.5 text-sm font-medium text-[#ff4d4f] hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
{changing ? "提交中…" : "确认修改"}
</button>
</form>
</div>
{!showChangeForm ? (
<button
type="button"
onClick={() => setShowChangeForm(true)}
className="mt-4 w-full rounded-lg border border-[#ff4d4f] py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
>
</button>
) : (
<form onSubmit={handleChangePassword} className="mt-4 space-y-3">
<input
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setChangeError(null); }}
placeholder="新密码(至少 8 位)"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
<input
type="password"
value={passwordConfirm}
onChange={(e) => { setPasswordConfirm(e.target.value); setChangeError(null); }}
placeholder="确认新密码"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
{changeError && <p className="text-sm text-red-600 dark:text-red-400">{changeError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowChangeForm(false)}
className="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800"
>
</button>
<button
type="submit"
disabled={changing}
className="flex-1 rounded-lg bg-[#ff4d4f] py-2.5 font-semibold text-white disabled:opacity-70"
>
{changing ? "提交中…" : "确认修改"}
</button>
</div>
</form>
)}
<button
type="button"
onClick={() => setShowPasswordModal(false)}
className="mt-3 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
>
</button>
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
@@ -38,24 +39,35 @@ export async function POST(request: NextRequest) {
}
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
const patchBody = { password: newPassword, passwordConfirm };
let res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify({
password: newPassword,
passwordConfirm: passwordConfirm,
}),
body: JSON.stringify(patchBody),
});
if (!res.ok) {
const adminToken = await getAdminToken();
if (adminToken) {
res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify(patchBody),
});
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return NextResponse.json(
{ ok: false, error: err?.message || "修改失败" },
{ status: 400 }
);
const msg = err?.message || (err?.data && typeof err.data === "object" && Object.values(err.data)[0] && (Object.values(err.data)[0] as { message?: string })?.message) || "修改失败";
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
}
const r = NextResponse.json({ ok: true });

View File

@@ -1,10 +1,8 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
const COOKIE_NAME = "pb_session";
export async function POST() {
export async function POST(request: NextRequest) {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -1,10 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
async function checkVip(userId: string): Promise<boolean> {
try {
const token = await getAdminToken();
if (!token) return false;
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}` }, cache: "no-store" }
);
if (!res.ok) return false;
const data = await res.json();
const items = data?.items ?? [];
const record = items[0];
if (!record) return false;
const now = new Date().toISOString();
const isExpired = record?.expires_at && record.expires_at < now;
return !isExpired;
} catch {
return false;
}
}
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
@@ -16,12 +39,14 @@ export async function GET(request: NextRequest) {
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/users/refresh`, {
const res = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) {
const r = NextResponse.json({ ok: true, user: null });
r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
r.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
return r;
}
const data = await res.json();
@@ -34,13 +59,13 @@ export async function GET(request: NextRequest) {
email: newRecord.email ?? email,
});
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
const vip = await checkVip(newRecord.id);
const r = NextResponse.json({
ok: true,
user: { id: newRecord.id, email: newRecord.email ?? email },
user: { id: newRecord.id, email: newRecord.email ?? email, vip },
token: newToken,
});
r.cookies.set(COOKIE_NAME, encoded, {
domain: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
@@ -49,9 +74,11 @@ export async function GET(request: NextRequest) {
});
return r;
}
const uid = data.record?.id ?? userId;
const vip = uid ? await checkVip(uid) : false;
return NextResponse.json({
ok: true,
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
user: { id: uid, email: data.record?.email ?? email, vip },
token: data.token,
});
} catch {

View File

@@ -1,8 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
@@ -20,13 +17,6 @@ export async function POST(request: NextRequest) {
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, encoded, {
domain: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -1,40 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
function getOrCreateUserId(request: NextRequest): string {
function getUserIdFromCookie(request: NextRequest): string | null {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (cookie) {
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
if (payload?.userId) return payload.userId;
} catch {
/* ignore */
}
if (!cookie) return null;
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
return payload?.userId ?? null;
} catch {
return null;
}
return `user${Date.now()}`;
}
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;
}
export async function POST(request: NextRequest) {
try {
const userId = getOrCreateUserId(request);
const userId = getUserIdFromCookie(request);
if (!userId) {
return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 });
}
const body = await request.json();
const formData = body && typeof body === "object" ? body : {};
@@ -73,27 +59,55 @@ export async function POST(request: NextRequest) {
const pb = getPocketBaseConfig();
const collection = pb.joinCollection;
const base = pb.url.replace(/\/$/, "");
const doCreate = async (authToken?: string) => {
const doUpsert = async (authToken: string) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
};
if (authToken) headers["Authorization"] = authToken;
return fetch(`${pb.url}/api/collections/${collection}/records`, {
const filter = `user_id="${userId}"`;
const listRes = await fetch(
`${base}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${authToken}` }, cache: "no-store" }
);
const listData = await listRes.json().catch(() => ({}));
const existing = (listData?.items ?? [])[0];
if (existing?.id) {
const patchBody: Record<string, string | number> = {
nickname: record.nickname,
occupation: record.occupation,
reason: record.reason,
single: record.single,
wechatId: record.wechatId,
gender: record.gender,
education: record.education,
gradYear: record.gradYear,
phone: record.phone,
};
if ("media" in record) patchBody.media = record.media as string;
return fetch(`${base}/api/collections/${collection}/records/${existing.id}`, {
method: "PATCH",
headers,
body: JSON.stringify(patchBody),
});
}
return fetch(`${base}/api/collections/${collection}/records`, {
method: "POST",
headers,
body: JSON.stringify(record),
});
};
let res = await doCreate();
if (res.status === 403) {
const token = await getAdminToken();
if (token) res = await doCreate(token);
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
}
const res = await doUpsert(token);
if (!res.ok) {
const errText = await res.text();
console.error("PocketBase create error:", res.status, errText);

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
function getUserIdFromCookie(request: NextRequest): string | null {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) return null;
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
return payload?.userId ?? null;
} catch {
return null;
}
}
const REQUIRED_FIELDS = ["nickname", "occupation", "reason", "single", "wechatId", "gender", "phone"];
function isRecordComplete(record: Record<string, unknown>): boolean {
return REQUIRED_FIELDS.every((key) => {
const v = record[key];
return v != null && String(v).trim() !== "";
});
}
export async function GET(request: NextRequest) {
try {
const userId = getUserIdFromCookie(request);
if (!userId) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const pb = getPocketBaseConfig();
const base = pb.url.replace(/\/$/, "");
const filter = `user_id="${userId}"`;
const res = await fetch(
`${base}/api/collections/${pb.joinCollection}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }
);
if (!res.ok) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const data = await res.json().catch(() => ({}));
const record = (data?.items ?? [])[0];
if (!record) {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
const isComplete = isRecordComplete(record as Record<string, unknown>);
return NextResponse.json({ ok: true, hasRecord: true, isComplete });
} catch {
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
}
}

View File

@@ -0,0 +1,98 @@
import { NextRequest } from "next/server";
import { DEFAULT_PAYMENT_API_URL } from "@/config/domain.config";
import { getPaymentConfig } from "@/config/services.config";
function isPrivateHost(hostname: string): boolean {
if (!hostname) return false;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return true;
}
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
return true;
}
const match = hostname.match(/^172\.(\d{1,2})\./);
if (!match) return false;
const secondOctet = Number(match[1]);
return secondOctet >= 16 && secondOctet <= 31;
}
function normalizeBaseUrl(url: string | undefined): string {
return String(url || "").trim().replace(/\/$/, "");
}
function getRequestHostname(request: NextRequest): string {
const forwardedHost = request.headers.get("x-forwarded-host");
const host = forwardedHost || request.headers.get("host") || "";
return host.split(",")[0].trim().replace(/:\d+$/, "");
}
function getMeetupApiCandidates(request: NextRequest): string[] {
const configuredApiUrl = normalizeBaseUrl(getPaymentConfig().apiUrl);
const localApiUrl = normalizeBaseUrl(DEFAULT_PAYMENT_API_URL);
const hostname = getRequestHostname(request);
const shouldPreferLocal =
process.env.NODE_ENV === "development" || isPrivateHost(hostname);
const urls = shouldPreferLocal
? [localApiUrl, configuredApiUrl]
: [configuredApiUrl, localApiUrl];
return urls.filter((url, index) => url && urls.indexOf(url) === index);
}
/** 上游 API 请求超时时间(毫秒),避免 serverless 超时导致 502 */
const MEETUP_API_TIMEOUT_MS = 20000;
export async function postMeetupApi(
request: NextRequest,
path: string,
body: unknown
): Promise<{ status: number; data: unknown }> {
const apiCandidates = getMeetupApiCandidates(request);
if (apiCandidates.length === 0) {
throw new Error("PAYMENT_API_URL is not configured");
}
let lastResponse: { status: number; data: unknown } | null = null;
let lastError: unknown = null;
for (const apiUrl of apiCandidates) {
const url = `${apiUrl}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MEETUP_API_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await res.json().catch(() => ({}));
if (res.ok || (res.status !== 404 && res.status < 500)) {
return { status: res.status, data };
}
lastResponse = { status: res.status, data };
if (res.status >= 500) {
console.error(`[meetup-api] ${path} ${apiUrl} returned ${res.status}`, data);
}
} catch (error) {
clearTimeout(timeoutId);
const errMsg = error instanceof Error ? error.message : String(error);
const errName = error instanceof Error && "name" in error ? (error as { name?: string }).name : "";
const isTimeout = errName === "AbortError" || errMsg.toLowerCase().includes("abort") || errMsg.toLowerCase().includes("timeout");
console.error(`[meetup-api] ${path} ${apiUrl} failed:`, isTimeout ? "timeout" : errMsg);
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("Meetup API request failed");
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { postMeetupApi } from "../_paymentApi";
/** Vercel serverless 超时时间(秒),避免上游慢导致 502 */
export const maxDuration = 20;
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const email = String(body?.email || "").trim();
if (!email) {
return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 });
}
const { status, data } = await postMeetupApi(request, "/api/meetup/check-user", { email });
// 上游返回 5xx 时,统一为 503 并返回友好提示,避免直接透传 502
if (status >= 500) {
const payload = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
return NextResponse.json(
{ ok: false, error: (payload.error as string) || "服务暂时不可用,请稍后重试" },
{ status: 503 }
);
}
return NextResponse.json(data, { status });
} catch (error) {
const isTimeout =
error instanceof Error &&
(error.name === "AbortError" || error.message.toLowerCase().includes("abort"));
console.error("check-user error:", error);
return NextResponse.json(
{
ok: false,
error: isTimeout
? "请求超时,请确认 payjsapi 已启动cd payjsapi && python run.py"
: "服务暂时不可用,请稍后重试",
},
{ status: 503 }
);
}
}

View File

@@ -0,0 +1,227 @@
import { NextRequest, NextResponse } from "next/server";
import {
getApiUrlFromRequest,
getPocketBaseConfig,
getPaymentConfig,
SITE_ID,
} from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
const DEFAULT_PASSWORD = "12345678";
function parseOrderId(orderId: string): { userId: string; payType: string } {
if (!orderId.includes("_order_")) return { userId: "", payType: "unknown" };
const prefix = orderId.split("_order_")[0];
if (prefix.includes("_")) {
const lastUnderscore = prefix.lastIndexOf("_");
return {
userId: prefix.slice(0, lastUnderscore),
payType: prefix.slice(lastUnderscore + 1).toLowerCase(),
};
}
return { userId: prefix, payType: "unknown" };
}
function decodePendingEmail(userId: string): string | null {
if (!userId.startsWith("pending_")) return null;
try {
return Buffer.from(userId.slice(8), "hex").toString("utf-8");
} catch {
return null;
}
}
async function verifyOrderPaid(request: NextRequest, orderId: string): Promise<boolean> {
const config = getPaymentConfig();
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = (
getApiUrlFromRequest(hostHeader) ||
process.env.PAYMENT_API_URL ||
config.apiUrl
).replace(/\/$/, "");
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const url = `${apiUrl}/cnomadcna/order_status?order_id=${encodeURIComponent(orderId)}`;
console.log(`complete-order: check payjsapi order_id=${orderId} url=${url}`);
const res = await fetch(url, { cache: "no-store", signal: controller.signal }).finally(() =>
clearTimeout(timeout)
);
const data = await res.json().catch(() => ({}));
if (data?.paid) {
console.log(`complete-order: payjsapi paid order_id=${orderId}`);
return true;
}
} catch (e) {
console.error(`complete-order: payjsapi fetch error order_id=${orderId}`, e);
}
const xorpay = await queryXorPayOrderStatus(orderId, 5000);
console.log(
`complete-order: xorpay fallback order_id=${orderId} paid=${!!xorpay?.paid} status=${xorpay?.status || ""} error=${xorpay?.error || ""}`
);
if (xorpay?.paid) return true;
const zpay = await queryZPayOrderStatus(orderId, 5000);
console.log(
`complete-order: zpay fallback order_id=${orderId} paid=${!!zpay?.paid} status=${zpay?.status || ""} error=${zpay?.error || ""}`
);
return !!zpay?.paid;
}
async function ensureSiteVip(
base: string,
adminToken: string,
userId: string,
orderId: string,
): Promise<boolean> {
try {
const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`;
const checkRes = await fetch(
`${base}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
);
if (checkRes.ok) {
const checkData = await checkRes.json();
const existing = (checkData?.items ?? [])[0];
if (existing) {
await fetch(`${base}/api/collections/site_vip/records/${existing.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ order_id: orderId, expires_at: "" }),
});
return true;
}
}
const createRes = await fetch(`${base}/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, expires_at: "" }),
});
return createRes.ok;
} catch (e) {
console.error("ensureSiteVip error:", e);
return false;
}
}
async function createUserByEmail(base: string, adminToken: string, email: string): Promise<string | null> {
const createRes = await fetch(`${base}/api/collections/users/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ email, password: DEFAULT_PASSWORD, passwordConfirm: DEFAULT_PASSWORD }),
});
if (createRes.ok) {
const data = await createRes.json();
return data?.id ?? null;
}
if (createRes.status === 400) {
const filter = `email="${email}"`;
const getRes = await fetch(
`${base}/api/collections/users/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
);
if (getRes.ok) {
const getData = await getRes.json();
return (getData?.items ?? [])[0]?.id ?? null;
}
}
return null;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const orderId = String(body?.order_id || "").trim();
if (!orderId) {
return NextResponse.json({ ok: false, error: "Missing order_id" }, { status: 400 });
}
let { userId, payType } = parseOrderId(orderId);
if (!userId || payType !== "meetup") {
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
}
const adminToken = await getAdminToken();
if (!adminToken) {
console.error("complete-order: getAdminToken failed, check POCKETBASE_EMAIL/PASSWORD");
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
}
const pb = getPocketBaseConfig();
const base = pb.url.replace(/\/$/, "");
// 1. 先查 site_vip 是否已由异步通知创建
const filter = `order_id = "${orderId}"`;
const vipRes = await fetch(
`${base}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
);
let found = false;
if (vipRes.ok) {
const vipData = await vipRes.json();
found = (vipData?.items ?? []).length > 0;
}
// 2. 没找到 → 查 ZPAY 确认是否已支付,已支付则直接创建 site_vip
if (!found) {
const paid = await verifyOrderPaid(request, orderId);
if (!paid) {
console.error(`complete-order: ZPAY 未确认支付 order_id=${orderId}`);
return NextResponse.json({ ok: false, error: "订单不存在或未支付成功" }, { status: 400 });
}
console.log(`complete-order: ZPAY 已确认支付,主动创建 site_vip order_id=${orderId}`);
// pending_ 新用户:先创建用户
let realUserId = userId;
if (userId.startsWith("pending_")) {
const email = decodePendingEmail(userId);
if (!email) {
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
}
const newId = await createUserByEmail(base, adminToken, email);
if (!newId) {
return NextResponse.json({ ok: false, error: "创建用户失败" }, { status: 500 });
}
realUserId = newId;
}
const created = await ensureSiteVip(base, adminToken, realUserId, orderId);
if (!created) {
return NextResponse.json({ ok: false, error: "VIP 开通失败" }, { status: 500 });
}
}
// 3. pending_ 新用户:登录并返回 token
if (userId.startsWith("pending_")) {
const email = decodePendingEmail(userId);
if (!email) {
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
}
const loginRes = await fetch(`${base}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password: DEFAULT_PASSWORD }),
});
if (!loginRes.ok) {
return NextResponse.json({ ok: false, error: "登录失败,请稍后重试" }, { status: 500 });
}
const authData = await loginRes.json();
return NextResponse.json({
ok: true,
token: authData.token,
record: authData.record,
is_new: true,
});
}
return NextResponse.json({ ok: true, token: null, record: null, is_new: false });
} catch (error) {
console.error("complete-order error:", error);
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
}
}

View File

@@ -1,110 +1,30 @@
/**
* 加入游牧中国:确保用户存在
* 新用户:用默认密码 123456789 创建,支付成功后需修改
* 老用户:验证密码后登录
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { postMeetupApi } from "../_paymentApi";
const DEFAULT_PASSWORD = "123456789";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
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;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const email = String(body?.email || "").trim();
const password = String(body?.password || "").trim();
if (!email) {
return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 });
return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 });
}
const pb = getPocketBaseConfig();
const tryLogin = async (pwd: string) => {
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: pwd }),
});
return res;
};
let loginRes = await tryLogin(password || DEFAULT_PASSWORD);
if (loginRes.ok) {
const data = await loginRes.json();
const res = NextResponse.json({
ok: true,
user_id: data.record?.id,
token: data.token,
record: data.record,
is_new: false,
});
return res;
}
const errData = await loginRes.json().catch(() => ({}));
const isNotFound = loginRes.status === 400 && (errData?.message?.includes("Invalid") || errData?.message?.includes("identity"));
if (!isNotFound && password) {
return NextResponse.json({ ok: false, error: "密码错误" }, { status: 400 });
}
const adminToken = await getAdminToken();
if (!adminToken) {
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
}
const createRes = await fetch(`${pb.url}/api/collections/users/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
email,
password: DEFAULT_PASSWORD,
passwordConfirm: DEFAULT_PASSWORD,
}),
const { status, data } = await postMeetupApi(request, "/api/meetup/ensure-user", {
email,
password: password || undefined,
});
if (!createRes.ok) {
const createErr = await createRes.json().catch(() => ({}));
if (createRes.status === 400 && createErr?.data?.email?.message?.includes("already")) {
return NextResponse.json({ ok: false, error: "该邮箱已注册,请输入密码登录" }, { status: 400 });
}
return NextResponse.json({ ok: false, error: createErr?.message || "创建账号失败" }, { status: 500 });
}
const payload = typeof data === "object" && data !== null ? data as Record<string, unknown> : null;
const newUser = await createRes.json();
const finalLogin = await tryLogin(DEFAULT_PASSWORD);
if (!finalLogin.ok) {
return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 });
const nextRes = NextResponse.json(data, { status });
if (status >= 200 && status < 300 && payload?.is_new) {
nextRes.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
}
const authData = await finalLogin.json();
const res = NextResponse.json({
ok: true,
user_id: authData.record?.id,
token: authData.token,
record: authData.record,
is_new: true,
});
res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
return res;
} catch (e) {
console.error("ensure-user error:", e);
return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 });
return nextRes;
} catch (error) {
console.error("ensure-user error:", error);
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
return res;
}

View File

@@ -1,30 +1,226 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig } from "@/config/services.config";
import {
getApiUrlFromRequest,
getJoinPaymentConfig,
getPaymentConfig,
SITE_ID,
} from "@/config/services.config";
import { SITE_URL } from "@/app/lib/env";
async function createPayOrder(
user_id: string,
return_url: string,
total_fee?: number,
type?: string,
channel = "alipay"
) {
const config = getPaymentConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
const ORDER_COOKIE = "join_pay_order";
const payload = {
user_id,
site_id: "meetup",
total_fee: Number(total_fee) || config.defaultAmount,
type: type || config.defaultType,
function resolvePayApiUrl(request: NextRequest): string {
const config = getPaymentConfig();
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
return (
getApiUrlFromRequest(hostHeader) ||
process.env.PAYMENT_API_URL ||
config.apiUrl
).replace(/\/$/, "");
}
function setPayOrderCookie(response: NextResponse, orderId: string) {
response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, {
path: "/",
maxAge: 900,
});
}
function escapeAttr(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function buildPayHtml(
payUrl: string,
params: Record<string, unknown>,
options?: { directToCashier?: boolean; orderId?: string }
): string {
const inputs = Object.entries(params)
.filter(([, value]) => value != null && String(value).trim() !== "")
.map(
([key, value]) =>
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
String(value)
)}" />`
)
.join("\n");
const pendingOrderScript = options?.orderId
? `<script>(function(){var v=${JSON.stringify(
`${options.orderId}|${Date.now()}`
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
: "";
return options?.directToCashier
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(
payUrl
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(
payUrl
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
}
function buildQrHtml(
imgSrc: string,
returnUrl: string,
orderId: string,
opts?: { channel?: string; successDesc?: string; confirmUrl?: string }
): string {
const ch = (opts?.channel || "wxpay").toLowerCase();
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
const successDesc = opts?.successDesc || "支付成功,即将跳转";
const confirmUrl = opts?.confirmUrl || "/api/pay/confirm";
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<style>
*{box-sizing:border-box;}
body{font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);}
.card{background:#fff;border-radius:20px;padding:40px;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);max-width:420px;}
h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
.hint{color:#64748b;font-size:14px;margin-bottom:20px;}
.wait-row{display:flex;align-items:center;justify-content:center;gap:24px;margin:20px 0;}
.qr-wrap img{width:200px;height:200px;border-radius:12px;border:2px solid #e2e8f0;background:#fff;}
.countdown-wrap{width:72px;height:72px;flex-shrink:0;position:relative;}
.countdown-ring{transform:rotate(-90deg);}
.countdown-ring circle{fill:none;stroke-width:5;transition:stroke-dashoffset .5s linear;}
.countdown-ring .bg{stroke:#e2e8f0;}
.countdown-ring .progress{stroke:#22c55e;stroke-linecap:round;}
.countdown-inner{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
.countdown-num{font-size:1rem;font-weight:700;color:#1e293b;line-height:1;}
.countdown-unit{font-size:9px;color:#94a3b8;}
.tip{font-size:13px;color:#64748b;margin-top:16px;}
.success-wrap{display:none;}
.success-wrap.show{display:block;}
.wait-wrap.hide{display:none;}
.success-icon{font-size:4rem;margin-bottom:16px;animation:scaleIn .4s ease;}
.success-title{font-size:1.25rem;color:#16a34a;font-weight:600;margin-bottom:8px;}
.success-desc{color:#64748b;font-size:14px;margin-bottom:24px;}
.success-countdown{font-size:1.5rem;font-weight:700;color:#667eea;}
@keyframes scaleIn{from{transform:scale(0);opacity:0;}to{transform:scale(1);opacity:1;}}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.7;}}
.pulse{animation:pulse 2s ease-in-out infinite;}
</style></head>
<body>
<div class="card">
<div class="wait-wrap" id="waitWrap">
<h2>${esc(title)}</h2>
<p class="hint">${esc(hint)}</p>
<div class="wait-row">
<div class="countdown-wrap">
<svg class="countdown-ring" width="72" height="72" viewBox="0 0 72 72">
<circle class="bg" cx="36" cy="36" r="32"/>
<circle class="progress" id="progressCircle" cx="36" cy="36" r="32" stroke-dasharray="201" stroke-dashoffset="0"/>
</svg>
<div class="countdown-inner">
<span class="countdown-num" id="countdownNum">5:00</span>
<span class="countdown-unit">分</span>
</div>
</div>
<div class="qr-wrap">
<img src="${imgSrc}" alt="支付二维码" />
</div>
</div>
<p class="tip pulse" id="tip">等待支付中...</p>
</div>
<div class="success-wrap" id="successWrap">
<div class="success-icon">✅</div>
<div class="success-title">支付成功</div>
<div class="success-desc">${esc(successDesc)}</div>
<div class="success-countdown" id="successCountdown">3 秒后跳转</div>
</div>
</div>
<script>
(function(){
try{localStorage.setItem("join_pay_order",${JSON.stringify(`${orderId}|${Date.now()}`)});}catch(e){}
var orderId=${JSON.stringify(orderId)};
var returnUrl=${JSON.stringify(returnUrl)};
if(!orderId){return;}
var waitWrap=document.getElementById("waitWrap");
var successWrap=document.getElementById("successWrap");
var tip=document.getElementById("tip");
var countdownNum=document.getElementById("countdownNum");
var progressCircle=document.getElementById("progressCircle");
var successCountdown=document.getElementById("successCountdown");
var maxT=300,left=maxT;
var circumference=2*Math.PI*32;
function fmt(t){var m=Math.floor(t/60),s=t%60;return m+":"+(s<10?"0":"")+s;}
var countdownTimer=setInterval(function(){
left--;
countdownNum.textContent=left>60?fmt(left):left;
progressCircle.style.strokeDashoffset=circumference*(1-left/maxT);
if(left<=0){clearInterval(countdownTimer);tip.textContent="支付超时,返回首页";setTimeout(function(){window.location.href="/";},800);}
},1000);
function showSuccess(){
clearInterval(countdownTimer);
waitWrap.classList.add("hide");
successWrap.classList.add("show");
var s=3;
var t=setInterval(function(){
s--;
successCountdown.textContent=s>0?s+" 秒后跳转":"跳转中…";
if(s<=0){clearInterval(t);window.location.href=returnUrl;}
},1000);
}
var confirmUrl=${JSON.stringify(confirmUrl)};
function doConfirm(attempt){
if(attempt>=3){showSuccess();return;}
fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({order_id:orderId})})
.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),{cache:"no-store"})
.then(function(r){return r.json();})
.then(function(d){
if(d.ok&&d.paid){doConfirm(0);return;}
setTimeout(check,500);
})
.catch(function(){setTimeout(check,500);});
}
setTimeout(check,500);
})();
</script>
</body></html>`;
}
async function createPayOrder(
apiUrl: string,
userId: string,
returnUrl: string,
totalFee: number,
type: string,
channel: string,
provider?: "xorpay",
clientIp?: string
) {
const payload: Record<string, unknown> = {
user_id: userId,
site_id: SITE_ID,
total_fee: totalFee,
type,
channel,
return_url,
return_url: returnUrl,
...(provider ? { provider } : {}),
...(clientIp ? { client_ip: clientIp } : {}),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/cnomadcna/payh5`, {
const res = await fetch(`${apiUrl}/cnomadcna/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -33,15 +229,42 @@ async function createPayOrder(
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.status !== "ok") {
console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data);
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
}
const params = data.params || data.xorpay_params || {};
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
if (!params || typeof params !== "object") {
throw new Error("payjsapi 未返回支付参数");
return {
params: (data.params ?? data.xorpay_params ?? {}) as Record<string, string>,
payUrl:
data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl,
provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(),
orderId: String(
data.order_id ||
data.params?.out_trade_no ||
data.xorpay_params?.order_id ||
""
).trim(),
};
}
async function requestRedirect(
apiUrl: string,
path: string,
payload: Record<string, unknown>
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
return await fetch(`${apiUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
redirect: "manual",
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
return { params: JSON.parse(JSON.stringify(params)), payUrl };
}
export async function POST(request: NextRequest) {
@@ -49,50 +272,199 @@ export async function POST(request: NextRequest) {
let body: Record<string, string | number>;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = await request.json();
body = (await request.json()) as Record<string, string | number>;
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record<string, string>;
}
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`;
const total_fee = Number(body?.total_fee) || 100;
const type = String(body?.type || "meetup");
const channel = String(body?.channel || "alipay");
const joinConfig = getJoinPaymentConfig();
const payConfig = getPaymentConfig();
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = resolvePayApiUrl(request);
const userId = String(body?.user_id || "").trim();
const totalFee = Number(body?.total_fee) || joinConfig.amount;
const type = String(body?.type || joinConfig.type);
const channel = String(body?.channel || "wxpay");
const device = String(body?.device || "pc");
const forcedProvider = device === "wechat" ? "xorpay" : undefined;
if (!user_id) {
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
if (!userId) {
return NextResponse.json(
{ ok: false, error: "缺少 user_id" },
{ status: 400 }
);
}
const { params, payUrl } = await createPayOrder(user_id, return_url, total_fee, type, channel);
const origin =
request.headers.get("x-forwarded-host")
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
: request.headers.get("origin") || SITE_URL;
const returnUrl =
String(body?.return_url || "").trim() || `${origin}/zh/join/paid`;
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
const wantHtml =
String(body?.html) === "1" ||
(request.headers.get("accept") || "").includes("text/html");
const wantHtml = String(body?.html) === "1" || (request.headers.get("accept") || "").includes("text/html");
if (wantHtml) {
const escapeAttr = (s: string) =>
String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const inputs = Object.entries(params as Record<string, unknown>)
.filter(([, v]) => v != null && String(v).trim() !== "")
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(String(v))}" />`)
.join("\n");
const orderId = String((params as Record<string, unknown>)?.out_trade_no || "").trim();
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>跳转支付...</title></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
if (orderId) {
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
console.log(
"[pay] cnomadcna apiUrl=%s host=%s provider=%s device=%s",
apiUrl,
hostHeader,
forcedProvider || "default",
device
);
const created = await createPayOrder(
apiUrl,
userId,
returnUrl,
totalFee,
type,
channel,
forcedProvider,
clientIp
);
if (!wantHtml) {
const response = NextResponse.json({
ok: true,
params: created.params,
payUrl: created.payUrl,
provider: created.provider,
order_id: created.orderId,
});
if (created.orderId) setPayOrderCookie(response, created.orderId);
return response;
}
if (created.provider === "xorpay") {
const html = buildPayHtml(created.payUrl, created.params, {
directToCashier: true,
orderId: created.orderId,
});
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (created.orderId) setPayOrderCookie(response, created.orderId);
return response;
}
const redirectPayload: Record<string, unknown> = {
user_id: userId,
site_id: SITE_ID,
total_fee: totalFee,
type,
channel,
return_url: returnUrl,
device,
...(clientIp ? { client_ip: clientIp } : {}),
};
try {
const redirectRes = await requestRedirect(
apiUrl,
"/cnomadcna/payh5/redirect",
redirectPayload
);
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
const location = redirectRes.headers.get("location");
if (location) {
const response = NextResponse.redirect(location);
const orderId =
redirectRes.headers.get("x-pay-order-id")?.trim() ||
created.orderId;
if (orderId) setPayOrderCookie(response, orderId);
return response;
}
}
return res;
const resData = await redirectRes.json().catch(() => ({}));
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
const safeReturnUrl = String(resData.return_url || returnUrl).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const imgSrc = String(resData.img).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c)
);
const rawOrderId = String(resData.order_id || created.orderId).trim();
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId, {
channel: resData.channel || "wxpay",
successDesc: "支付成功,申请已提交",
confirmUrl: "/api/meetup/complete-order",
});
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (rawOrderId) setPayOrderCookie(response, rawOrderId);
return response;
}
if (redirectRes.status === 200 && resData?.use_form) {
const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
directToCashier: true,
orderId: String(resData.order_id || created.orderId).trim(),
});
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
const orderId = String(resData.order_id || created.orderId).trim();
if (orderId) setPayOrderCookie(response, orderId);
return response;
}
if (redirectRes.status >= 400) {
console.warn(
"[pay] redirect fallback status=%s body=%j",
redirectRes.status,
resData
);
}
} catch (error) {
console.warn(
"[pay] redirect fallback error=%s",
error instanceof Error ? error.message : String(error)
);
}
return NextResponse.json({ ok: true, pay_url: payUrl, params, provider: "zpay" });
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
const html = buildPayHtml(created.payUrl, created.params, {
orderId: created.orderId,
});
const response = new NextResponse(html, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
if (created.orderId) setPayOrderCookie(response, created.orderId);
return response;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const msg = err.message || "";
const hint =
msg.includes("fetch") || msg.includes("ECONNREFUSED")
? "请确认 payjsapi 已启动cd C:\\project\\payjsapi && python run.py"
: err.name === "AbortError"
? "请求超时,请检查 payjsapi 是否正常运行"
: msg;
console.error("Pay API error:", err);
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
return NextResponse.json(
{ ok: false, error: `支付请求失败: ${hint}` },
{ status: 500 }
);
}
}

View File

@@ -1,25 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig } from "@/config/services.config";
import {
getApiUrlFromRequest,
getPaymentConfig,
} from "@/config/services.config";
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
function resolvePayApiUrl(request: NextRequest): string {
const config = getPaymentConfig();
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
return (
getApiUrlFromRequest(hostHeader) ||
process.env.PAYMENT_API_URL ||
config.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 });
}
const config = getPaymentConfig();
if (!config.apiUrl) {
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
return NextResponse.json(
{ ok: false, error: "missing order_id" },
{ status: 400 }
);
}
const hostHeader =
request.headers.get("host") || request.headers.get("x-forwarded-host");
const apiUrl = resolvePayApiUrl(request);
const url = `${apiUrl}/cnomadcna/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);
const res = await fetch(
`${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${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 {
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 });
}

View File

@@ -1,23 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
import { getAdminToken } from "@/app/lib/pocketbase/admin";
const COOKIE_NAME = "pb_session";
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;
}
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {

View File

@@ -1,12 +1,11 @@
"use client";
import { useState } from "react";
import { useState, useCallback, useEffect } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { useTranslation } from "@/i18n/navigation";
import JoinModal from "./JoinModal";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
@@ -37,55 +36,139 @@ const features = [
{ icon: "💬", key: "feature5" as const },
];
function isPrivateDevHost(hostname: string): boolean {
if (!hostname) return false;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return true;
}
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
return true;
}
const match = hostname.match(/^172\.(\d{1,2})\./);
if (!match) return false;
const secondOctet = Number(match[1]);
return secondOctet >= 16 && secondOctet <= 31;
}
function buildPendingUserId(email: string): string {
return (
"pending_" +
Array.from(new TextEncoder().encode(email))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
);
}
export default function HeroSection() {
const { t } = useTranslation("common");
const { t: tHero } = useTranslation("hero");
const locale = useLocale();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [joinModalOpen, setJoinModalOpen] = useState(false);
const [joinModalVipOnly, setJoinModalVipOnly] = useState(false);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault();
const em = email.trim();
if (!em) {
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
return;
}
setError(null);
setLoading(true);
try {
const res = await fetch("/api/meetup/ensure-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: em, password: password || undefined }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
const data = await res.json();
setIsLoggedIn(!!data?.user);
} catch {
setIsLoggedIn(false);
}
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: data.token, record: data.record }),
});
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
const env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc";
const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
redirectToPay({
user_id: data.user_id,
return_url: returnUrl,
channel,
device: getDeviceFromEnv(env),
useJoinConfig: true,
});
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
checkAuth();
const onAuth = () => checkAuth();
window.addEventListener("auth:updated", onAuth);
return () => window.removeEventListener("auth:updated", onAuth);
}, []);
const handleCheckAndProceed = useCallback(
async (em: string) => {
if (checking) return;
const trimmed = em.trim();
if (!trimmed) {
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
return;
}
setError(null);
setChecking(true);
try {
const checkRes = await fetch("/api/meetup/check-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
credentials: "include",
});
const checkData = await checkRes.json();
if (!checkRes.ok || !checkData?.ok) {
throw new Error(checkData?.error || checkData?.detail || (locale === "zh" ? "操作失败" : "Operation failed"));
}
if (checkData.exists) {
if (checkData.vip) {
setJoinModalOpen(true);
setJoinModalVipOnly(true);
return;
}
setJoinModalVipOnly(false);
setJoinModalOpen(true);
return;
}
// 新用户:支付成功后才创建,用 pending_hex(email) 作为 user_id
const shouldPrecreateUser =
typeof window !== "undefined" &&
isPrivateDevHost(window.location.hostname);
let user_id = buildPendingUserId(trimmed);
if (shouldPrecreateUser) {
const ensureRes = await fetch("/api/meetup/ensure-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
credentials: "include",
});
const ensureData = await ensureRes.json();
if (!ensureRes.ok || !ensureData?.ok || !ensureData?.user_id) {
throw new Error(
ensureData?.error ||
ensureData?.detail ||
(locale === "zh" ? "创建用户失败" : "Failed to create user")
);
}
user_id = String(ensureData.user_id).trim();
if (ensureData?.token && ensureData?.record) {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: ensureData.token, record: ensureData.record }),
credentials: "include",
});
}
}
const returnUrl =
typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
const env = getPayEnv();
const channel: PayChannel = "wxpay";
redirectToPay({
user_id,
return_url: returnUrl,
channel,
device: getDeviceFromEnv(env),
useJoinConfig: true,
});
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setChecking(false);
}
},
[checking, locale]
);
const handleJoinClick = (e: React.FormEvent) => {
e.preventDefault();
handleCheckAndProceed(email);
};
return (
@@ -193,36 +276,40 @@ export default function HeroSection() {
</button>
</div>
<form onSubmit={handleJoin} className="p-4 sm:p-5">
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder={tHero("emailPlaceholder")}
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder={locale === "zh" ? "密码(老用户必填)" : "Password (required for existing users)"}
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
{error && (
<p className="mb-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98] disabled:opacity-70"
>
{loading ? (locale === "zh" ? "处理中…" : "Processing…") : tHero("ctaJoin")}
</button>
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
{locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"}
</p>
</form>
{!isLoggedIn ? (
<form onSubmit={handleJoinClick} className="p-4 sm:p-5">
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder={tHero("emailPlaceholder")}
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
{error && (
<p className="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={checking}
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98] disabled:opacity-70"
>
{checking ? (locale === "zh" ? "处理中…" : "Processing…") : tHero("ctaJoin")}
</button>
</form>
) : (
<div className="p-4 sm:p-5 text-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
{locale === "zh" ? "您已登录,可前往社区参与互动" : "You are logged in"}
</p>
</div>
)}
</div>
<JoinModal
isOpen={joinModalOpen}
onClose={() => { setJoinModalOpen(false); setJoinModalVipOnly(false); }}
initialEmail={email.trim()}
vipOnly={joinModalVipOnly}
/>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
<Link href="/dashboard" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">

View File

@@ -0,0 +1,156 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { createPortal } from "react-dom";
import { useLocale } from "@/i18n/navigation";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
interface JoinModalProps {
isOpen: boolean;
onClose: () => void;
initialEmail?: string;
/** 仅登录(已有 VIP 用户验证密码后直接登录,不跳转支付) */
vipOnly?: boolean;
}
export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly = false }: JoinModalProps) {
const locale = useLocale();
const [email, setEmail] = useState(initialEmail);
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setEmail(initialEmail);
setPassword("");
setError(null);
}
}, [isOpen, initialEmail]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
const em = email.trim();
if (!em) {
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
return;
}
setError(null);
setLoading(true);
try {
const res = await fetch("/api/meetup/ensure-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: em, password: password || undefined }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
}
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 }),
credentials: "include",
});
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:updated"));
}
if (vipOnly) {
onClose();
return;
}
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
const env = getPayEnv();
const channel: PayChannel = "wxpay";
redirectToPay({
user_id: data.user_id,
return_url: returnUrl,
channel,
device: getDeviceFromEnv(env),
useJoinConfig: true,
});
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!isOpen || !mounted) return null;
const modalContent = (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
<div className="flex min-h-svh items-center justify-center p-4 py-8">
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
{vipOnly ? (locale === "zh" ? "验证密码登录" : "Verify password to login") : (locale === "zh" ? "加入游牧中国" : "Join Nomad China")}
</h2>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{locale === "zh" ? "邮箱" : "Email"}
</label>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder={locale === "zh" ? "输入你的邮箱..." : "Enter your email..."}
required
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{locale === "zh" ? "密码" : "Password"}
</label>
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder={locale === "zh" ? "请输入密码" : "Enter your password"}
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
>
{loading ? (locale === "zh" ? "处理中…" : "Processing…") : vipOnly ? (locale === "zh" ? "登录" : "Login") : (locale === "zh" ? "加入游牧中国 →" : "Join Nomad China →")}
</button>
</form>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}

View File

@@ -7,6 +7,7 @@ import { getNavLinks } from "@/config";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
import SiteMenu from "./SiteMenu";
import UserAvatar from "./UserAvatar";
export default function NavbarComponent() {
const { t } = useTranslation("common");
@@ -18,33 +19,42 @@ export default function NavbarComponent() {
const [menuOpen, setMenuOpen] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [userVip, setUserVip] = useState(false);
const navLinks = getNavLinks();
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
const data = await res.json();
if (!mounted) return;
if (data?.user && data?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
setUserEmail(data.user.email);
return;
}
} catch {
/* ignore */
const fetchAuth = useCallback(async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const data = await res.json();
if (data?.user && data?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
setUserEmail(data.user.email);
setUserVip(!!data.user.vip);
return;
}
if (mounted) setUserEmail(getStoredUserEmail());
})();
return () => { mounted = false; };
} catch {
/* ignore */
}
setUserEmail(getStoredUserEmail());
setUserVip(false);
}, []);
useEffect(() => {
fetchAuth();
}, [fetchAuth]);
useEffect(() => {
const onAuthUpdated = () => fetchAuth();
window.addEventListener("auth:updated", onAuthUpdated);
return () => window.removeEventListener("auth:updated", onAuthUpdated);
}, [fetchAuth]);
const handleLogout = useCallback(async () => {
await pbLogout();
setUserEmail(null);
setUserVip(false);
}, []);
const isActive = useCallback((href: string) => {
@@ -114,12 +124,7 @@ export default function NavbarComponent() {
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-gray-200 dark:border-gray-700 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors whitespace-nowrap"
>
{t("logout")}
</button>
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { setUserEmail(null); setUserVip(false); }} />
</div>
) : null}
<button
@@ -182,10 +187,8 @@ export default function NavbarComponent() {
</button>
{userEmail ? (
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { handleLogout(); setMobileOpen(false); }} />
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
<button onClick={() => { handleLogout(); setMobileOpen(false); }} className="text-sm font-medium text-[#ff4d4f]">
{t("logout")}
</button>
</div>
) : null}
</div>

View File

@@ -0,0 +1,66 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "@/i18n/navigation";
import { pbLogout } from "@/app/lib/pocketbase";
function getAvatarLetter(email: string): string {
const local = email.split("@")[0];
if (!local) return "?";
return local.slice(0, 1).toUpperCase();
}
interface UserAvatarProps {
email: string;
isVip?: boolean;
onLogout?: () => void;
}
/** 圆形头像 + VIP 标识 + 点击展开退出按钮 */
export default function UserAvatar({ email, isVip = false, onLogout }: UserAvatarProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = async () => {
await pbLogout();
setOpen(false);
onLogout?.();
};
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ff4d4f] text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-gray-900 hover:bg-[#ff7a45]"
aria-label={t("logout")}
>
{getAvatarLetter(email)}
</button>
{isVip && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white shadow-sm">
VIP
</span>
)}
{open && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t("logout")}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,7 @@
@import "tailwindcss";
@import "tailwindcss" source(none);
@source "./";
@source not "./favicon.ico";
@source "../config";
@custom-variant dark (&:where(.dark, .dark *));

33
app/lib/auth-cookie.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 认证 Cookie 配置
* 生产环境设置 AUTH_COOKIE_DOMAIN 实现跨子域 SSO如 .hackrobot.cn
* 本地开发不设置 domain避免 IP 访问时 cookie 失效
*/
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export { COOKIE_NAME, COOKIE_MAX_AGE };
export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string {
const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || "";
return host.split(",")[0].trim().replace(/:\d+$/, "");
}
export function getCookieOptions(clear = false, requestHost?: string) {
const isProd = process.env.NODE_ENV === "production";
const domain = process.env.AUTH_COOKIE_DOMAIN?.trim();
const opts: Record<string, unknown> = {
path: "/",
maxAge: clear ? 0 : COOKIE_MAX_AGE,
secure: isProd,
sameSite: "lax" as const,
httpOnly: true,
};
if (domain && isProd) {
opts.domain = domain;
} else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
opts.domain = requestHost;
}
return opts;
}

View File

@@ -2,4 +2,4 @@ const isDev = process.env.NODE_ENV === "development";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ||
(isDev ? "http://localhost:3000" : "https://nomadcna.cn");
(isDev ? "http://localhost:3001" : "https://nomadcna.cn");

View File

@@ -30,8 +30,7 @@ export function getPayEnv(): PayEnv {
}
export function getRecommendedChannel(env: PayEnv): PayChannel {
if (env === "wechat") return "wxpay";
return "alipay";
return "wxpay"; // 默认微信支付PC/H5/微信内均优先)
}
export function mustUseWxPay(env: PayEnv): boolean {

View File

@@ -1,11 +1,9 @@
/**
* 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态
*/
import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "join_pay_order";
const POLL_INTERVAL = 2000;
const MAX_POLLS = 150;
const STORAGE_NAME = "join_pay_order";
const POLL_INTERVAL_MS = 1200;
const MAX_POLLS = 60;
const MAX_AGE_MS = 15 * 60 * 1000;
function getCookie(name: string): string | null {
@@ -19,7 +17,33 @@ function clearCookie(name: string): void {
document.cookie = `${name}=; path=/; max-age=0`;
}
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
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 persistPendingOrder(raw: string): void {
if (typeof document !== "undefined") {
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`;
}
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(STORAGE_NAME, raw);
} catch {}
}
}
function parsePendingOrder(raw: string | null): [string | null, boolean] {
if (!raw?.trim()) return [null, false];
const parts = raw.split("|");
const orderId = parts[0]?.trim() || raw.trim();
@@ -36,38 +60,96 @@ export function usePayStatusPoll(onPaid: () => void): boolean {
onPaidRef.current = onPaid;
useEffect(() => {
const raw = getCookie(COOKIE_NAME);
const [orderId, isValid] = parsePayOrderCookie(raw);
if (!orderId || !isValid) {
if (raw) clearCookie(COOKIE_NAME);
const cookieRaw = getCookie(COOKIE_NAME);
const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw);
const storageRaw = getStorage(STORAGE_NAME);
const [storageOrderId, storageValid] = parsePendingOrder(storageRaw);
const orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
const activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
if (!orderId) {
if (cookieRaw || storageRaw) {
clearPendingOrder();
}
return;
}
if (activeRaw) {
persistPendingOrder(activeRaw);
}
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 scheduleNext = () => {
if (stopped) return;
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
};
const tick = async () => {
if (stopped) return;
if (inFlight) {
scheduleNext();
return;
}
attempts += 1;
if (attempts > MAX_POLLS) {
stop();
clearPendingOrder();
setPolling(false);
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();
return;
}
} catch {
// 继续轮询
// keep polling
} finally {
inFlight = false;
}
}, POLL_INTERVAL);
return () => clearInterval(timer);
scheduleNext();
};
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();
};
}, []);
return polling;

View File

@@ -0,0 +1,49 @@
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";
export async function queryXorPayOrderStatus(
orderId: string,
timeoutMs = 5000
): Promise<{ paid: boolean; status?: string; error?: string; url: string } | null> {
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId || !XORPAY_AID || !XORPAY_SECRET) {
return null;
}
const sign = createHash("md5")
.update(`${normalizedOrderId}${XORPAY_SECRET}`, "utf8")
.digest("hex")
.toLowerCase();
const url = new URL(
`${XORPAY_QUERY_URL.replace(/\/$/, "")}/${encodeURIComponent(XORPAY_AID)}`
);
url.searchParams.set("order_id", normalizedOrderId);
url.searchParams.set("sign", sign);
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 {
paid: res.ok && (status === "payed" || status === "success"),
status,
url: url.toString(),
error: res.ok ? undefined : `status=${res.status}`,
};
} catch (error) {
return {
paid: false,
url: url.toString(),
error: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -0,0 +1,45 @@
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";
export async function queryZPayOrderStatus(
orderId: string,
timeoutMs = 5000
): Promise<{ paid: boolean; status?: string; error?: string; url: string } | 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 {
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 {
paid: false,
url: url.toString(),
error: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -0,0 +1,44 @@
import { getPocketBaseConfig } from "@/config/services.config";
let cachedToken: { token: string; expiresAt: number } | null = null;
const TOKEN_TTL_MS = 10 * 60 * 1000;
/**
* PocketBase v0.23+ 使用 _superusers 集合替代旧 /api/admins 端点。
* 此函数依次尝试新旧两个端点,兼容所有版本。
*/
export async function getAdminToken(): Promise<string | null> {
if (cachedToken && Date.now() < cachedToken.expiresAt) {
return cachedToken.token;
}
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const base = pb.url.replace(/\/$/, "");
const body = JSON.stringify({ identity: email, password });
const headers = { "Content-Type": "application/json" };
const endpoints = [
`${base}/api/collections/_superusers/auth-with-password`,
`${base}/api/admins/auth-with-password`,
];
for (const url of endpoints) {
try {
const res = await fetch(url, { method: "POST", headers, body });
if (!res.ok) continue;
const data = await res.json();
const token = data?.token;
if (token) {
cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS };
return token;
}
} catch {
continue;
}
}
return null;
}

View File

@@ -73,10 +73,11 @@ export async function pbLogout(): Promise<void> {
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
try {
await fetch("/api/auth/logout", { method: "POST" });
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
} catch {
/* ignore */
}
window.dispatchEvent(new CustomEvent("auth:updated"));
}
export function getStoredUserEmail(): string | null {

View File

@@ -8,11 +8,9 @@ const isDev = process.env.NODE_ENV === "development";
export const ROOT_DOMAIN =
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
export const AUTH_COOKIE_DOMAIN =
process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`;
/** 支付 API 默认地址(本地可用 PAYMENT_API_URL 覆盖) */
export const DEFAULT_PAYMENT_API_URL = isDev
? "http://127.0.0.1:8700"
? "http://127.0.0.1:8007"
: `https://api.${ROOT_DOMAIN}`;
export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;

View File

@@ -39,6 +39,7 @@ export interface MinioConfig {
}
const isDev = process.env.NODE_ENV === "development";
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
/** 站点标识,用于 VIP 按站点隔离 */
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup";
@@ -46,6 +47,13 @@ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup";
/** meetup 加入游牧中国 价格测试用1元 */
export const MEETUP_JOIN_AMOUNT = 100;
export function getApiUrlFromRequest(hostHeader: string | null): string | null {
if (!hostHeader) return null;
if (!isDev) return process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
const hostname = hostHeader.split(":")[0];
return `http://${hostname}:${PAYJSAPI_PORT}`;
}
export function getPocketBaseConfig(): PocketBaseConfig {
return {
enabled: true,

BIN
dev-server.log Normal file

Binary file not shown.

View File

@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
"172.27.128.1:3000",
"192.168.41.222",
"192.168.41.222:3000",
"192.168.41.222:3001",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://172.27.128.1:3000",

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0",
"dev": "next dev -p 3001 -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "eslint"