;s
This commit is contained in:
@@ -59,28 +59,28 @@ export default function JoinPage() {
|
||||
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(apiUrl("/api/auth/me"), { credentials: "include" });
|
||||
const meData = await meRes.json();
|
||||
if (!meData?.user) {
|
||||
setCheckingStatus(false);
|
||||
return;
|
||||
try {
|
||||
const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
|
||||
const meData = await meRes.json().catch(() => ({}));
|
||||
if (!meRes.ok || !meData?.user) {
|
||||
return;
|
||||
}
|
||||
const statusRes = await fetch(apiUrl("/api/join/status"), { credentials: "include", cache: "no-store" });
|
||||
const statusData = await statusRes.json().catch(() => ({}));
|
||||
if (statusRes.ok && statusData?.hasRecord && statusData?.isComplete) {
|
||||
setIsAlreadyRecorded(true);
|
||||
setSubmitted(true);
|
||||
}
|
||||
} catch {
|
||||
// Keep the form usable when the API proxy/backend is temporarily unavailable.
|
||||
}
|
||||
const statusRes = await fetch(apiUrl("/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();
|
||||
}, []);
|
||||
@@ -200,14 +200,6 @@ 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">
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useState, useEffect } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
function clearPendingOrder(): void {
|
||||
try {
|
||||
localStorage.removeItem("join_pay_order");
|
||||
@@ -16,16 +14,8 @@ function clearPendingOrder(): void {
|
||||
/**
|
||||
* 支付完成回跳页
|
||||
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
|
||||
* 新用户支付成功后弹窗显示默认密码 12345678,并提供修改密码选项
|
||||
*/
|
||||
export default function JoinPaidPage() {
|
||||
const [needPasswordChange, setNeedPasswordChange] = useState<boolean | null>(null);
|
||||
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);
|
||||
@@ -59,20 +49,6 @@ export default function JoinPaidPage() {
|
||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
||||
}, 400);
|
||||
if (d.is_new) {
|
||||
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
|
||||
if (!cancelled) {
|
||||
setNeedPasswordChange(true);
|
||||
setShowPasswordModal(true);
|
||||
}
|
||||
} else {
|
||||
const needRes = await fetch(apiUrl("/api/meetup/need-password-change"), { credentials: "include" });
|
||||
const needData = await needRes.json().catch(() => ({}));
|
||||
if (!cancelled && needData?.need === true) {
|
||||
setNeedPasswordChange(true);
|
||||
setShowPasswordModal(true);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
@@ -121,15 +97,6 @@ export default function JoinPaidPage() {
|
||||
setCompleteStatus("error");
|
||||
setErrorDetail(lastError);
|
||||
}
|
||||
|
||||
fetch(apiUrl("/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();
|
||||
|
||||
@@ -138,7 +105,7 @@ export default function JoinPaidPage() {
|
||||
|
||||
const COUNTDOWN_SECONDS = 5;
|
||||
useEffect(() => {
|
||||
if (completeStatus !== "success" || showPasswordModal) return;
|
||||
if (completeStatus !== "success") return;
|
||||
setCountdown(COUNTDOWN_SECONDS);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
@@ -152,46 +119,7 @@ export default function JoinPaidPage() {
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [completeStatus, showPasswordModal]);
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setChangeError(null);
|
||||
if (newPassword.length < 8) {
|
||||
setChangeError("密码至少 8 位");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== passwordConfirm) {
|
||||
setChangeError("两次密码不一致");
|
||||
return;
|
||||
}
|
||||
setChanging(true);
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/auth/change-password"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPassword, passwordConfirm }),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) {
|
||||
throw new Error(data?.error || "修改失败");
|
||||
}
|
||||
setShowPasswordModal(false);
|
||||
} catch (err) {
|
||||
setChangeError(err instanceof Error ? err.message : "修改失败");
|
||||
} finally {
|
||||
setChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DEFAULT_PASSWORD);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, [completeStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -232,83 +160,6 @@ export default function JoinPaidPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
请妥善保存以下默认密码,用于登录本网站(游牧中国)
|
||||
</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="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"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 修改密码 - 用于 meetup 支付成功后强制修改默认密码
|
||||
*/
|
||||
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";
|
||||
|
||||
function getAuthFromCookie(request: NextRequest): { userId: string; token: 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"));
|
||||
if (payload?.userId && payload?.token) return { userId: payload.userId, token: payload.token };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const newPassword = String(body?.newPassword || "").trim();
|
||||
const passwordConfirm = String(body?.passwordConfirm || "").trim();
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ ok: false, error: "密码至少 8 位" }, { status: 400 });
|
||||
}
|
||||
if (newPassword !== passwordConfirm) {
|
||||
return NextResponse.json({ ok: false, error: "两次密码不一致" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = getAuthFromCookie(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
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(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(() => ({}));
|
||||
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 });
|
||||
r.cookies.set(COOKIE_NEEDS_PW, "", { path: "/", maxAge: 0 });
|
||||
return r;
|
||||
} catch (e) {
|
||||
console.error("change-password error:", e);
|
||||
return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||
return res;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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";
|
||||
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) {
|
||||
return NextResponse.json({ ok: true, user: null });
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
|
||||
const { token, userId, email } = payload;
|
||||
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
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, "", { path: "/", maxAge: 0 });
|
||||
return r;
|
||||
}
|
||||
const data = await res.json();
|
||||
const newToken = data.token;
|
||||
const newRecord = data.record;
|
||||
if (newToken && newRecord?.id) {
|
||||
const newPayload = JSON.stringify({
|
||||
token: newToken,
|
||||
userId: newRecord.id,
|
||||
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, vip },
|
||||
token: newToken,
|
||||
});
|
||||
r.cookies.set(COOKIE_NAME, encoded, {
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
httpOnly: true,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
const uid = data.record?.id ?? userId;
|
||||
const vip = uid ? await checkVip(uid) : false;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: uid, email: data.record?.email ?? email, vip },
|
||||
token: data.token,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true, user: null });
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const token = body?.token;
|
||||
const record = body?.record;
|
||||
if (!token || !record?.id) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
token,
|
||||
userId: record.id,
|
||||
email: record.email ?? "",
|
||||
});
|
||||
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||
return res;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* 代理中国地图 GeoJSON,避免前端 CORS
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json"
|
||||
);
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch china map:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load map" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
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 : {};
|
||||
|
||||
const {
|
||||
nickname,
|
||||
gender,
|
||||
single,
|
||||
profession,
|
||||
intro,
|
||||
wechat,
|
||||
education,
|
||||
graduationYear,
|
||||
phone,
|
||||
media: mediaUrl,
|
||||
} = formData;
|
||||
|
||||
const record: Record<string, string | number> = {
|
||||
nickname: nickname || "",
|
||||
occupation: profession || "",
|
||||
reason: intro || "",
|
||||
single: single || "",
|
||||
wechatId: wechat || "",
|
||||
gender: gender || "",
|
||||
education: education || "",
|
||||
gradYear: graduationYear || "",
|
||||
phone: phone || "",
|
||||
user_id: userId,
|
||||
amount: 0,
|
||||
order_id: "",
|
||||
type: "",
|
||||
};
|
||||
|
||||
if (mediaUrl && typeof mediaUrl === "string") {
|
||||
(record as Record<string, string>)["media"] = mediaUrl;
|
||||
}
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const collection = pb.joinCollection;
|
||||
const base = pb.url.replace(/\/$/, "");
|
||||
|
||||
const doUpsert = async (authToken: string) => {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
let errMsg = "提交失败,请稍后重试";
|
||||
try {
|
||||
const errJson = JSON.parse(errText);
|
||||
if (errJson?.message) errMsg = errJson.message;
|
||||
if (errJson?.data && typeof errJson.data === "object") {
|
||||
const details = Object.entries(errJson.data)
|
||||
.map(([k, v]) => `${k}: ${(v as { message?: string })?.message || v}`)
|
||||
.join("; ");
|
||||
if (details) errMsg += ` (${details})`;
|
||||
}
|
||||
} catch {
|
||||
if (errText.length < 200) errMsg = errText;
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, user_id: userId });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("Join API error:", e);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `提交失败: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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) ||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { postMeetupApi } from "../_paymentApi";
|
||||
|
||||
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
||||
|
||||
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: "Please enter your email" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { status, data } = await postMeetupApi(request, "/api/meetup/ensure-user", {
|
||||
email,
|
||||
password: password || undefined,
|
||||
});
|
||||
const payload = typeof data === "object" && data !== null ? data as Record<string, unknown> : null;
|
||||
|
||||
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 });
|
||||
}
|
||||
return nextRes;
|
||||
} catch (error) {
|
||||
console.error("ensure-user error:", error);
|
||||
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const need = request.cookies.get(COOKIE_NEEDS_PW)?.value === "1";
|
||||
return NextResponse.json({ need });
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getJoinPaymentConfig,
|
||||
getPaymentConfig,
|
||||
SITE_ID,
|
||||
} from "@/config/services.config";
|
||||
import { SITE_URL } from "@/app/lib/env";
|
||||
|
||||
const ORDER_COOKIE = "join_pay_order";
|
||||
|
||||
function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (getApiUrlFromRequest(hostHeader) || 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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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: returnUrl,
|
||||
...(provider ? { provider } : {}),
|
||||
...(clientIp ? { client_ip: clientIp } : {}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const res = await fetch(`${apiUrl}/cnomadcna/payh5`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
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 || "支付创建失败");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
let body: Record<string, string | number>;
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/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 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 (!userId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "缺少 user_id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
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: `支付请求失败: ${hint}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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) || 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: "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(), 3000);
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const paid = !!data?.paid;
|
||||
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 });
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { uploadBuffer, generateObjectKey } from "@/app/lib/minio";
|
||||
|
||||
const MAX_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
const fileObj =
|
||||
file != null && typeof file === "object" && "size" in file
|
||||
? (file as File | Blob)
|
||||
: null;
|
||||
|
||||
if (!fileObj || fileObj.size === 0) {
|
||||
return NextResponse.json({ ok: false, error: "请选择文件" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (fileObj.size > MAX_SIZE) {
|
||||
return NextResponse.json({ ok: false, error: "文件大小不能超过 20MB" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext =
|
||||
(fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
|
||||
(fileObj.type?.startsWith("video/") ? "mp4" : "jpg");
|
||||
const objectKey = generateObjectKey(ext);
|
||||
|
||||
const buffer = Buffer.from(await fileObj.arrayBuffer());
|
||||
const contentType = (fileObj as File).type || "application/octet-stream";
|
||||
|
||||
const { url } = await uploadBuffer(buffer, objectKey, contentType);
|
||||
|
||||
return NextResponse.json({ ok: true, url });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("Upload media error:", e);
|
||||
return NextResponse.json({ ok: false, error: `上传失败: ${msg}` }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||
if (!cookie) {
|
||||
return NextResponse.json({ ok: true, vip: false });
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
|
||||
const userId = payload?.userId;
|
||||
if (!userId) return NextResponse.json({ ok: true, vip: false });
|
||||
|
||||
const token = await getAdminToken();
|
||||
if (!token) return NextResponse.json({ ok: true, vip: 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}` } }
|
||||
);
|
||||
if (!res.ok) return NextResponse.json({ ok: true, vip: false });
|
||||
const data = await res.json();
|
||||
const items = data?.items ?? [];
|
||||
const record = items[0];
|
||||
const now = new Date().toISOString();
|
||||
const isExpired = record?.expires_at && record.expires_at < now;
|
||||
const vip = !!record && !isExpired;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
vip,
|
||||
expires_at: record?.expires_at ?? null,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true, vip: false });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useCallback, useState, useEffect, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
pbLogin,
|
||||
pbRegister,
|
||||
emailLogin,
|
||||
googleLogin,
|
||||
pbSaveAuth,
|
||||
} from "@/app/lib/pocketbase";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
import GoogleLoginButton from "./GoogleLoginButton";
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,37 +17,16 @@ interface AuthModalProps {
|
||||
onSuccess: (email: string) => void;
|
||||
}
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
|
||||
export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (mode === "register") {
|
||||
if (password !== passwordConfirm) {
|
||||
setError(t("passwordMismatch"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError(t("passwordMinLength"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
result = await pbRegister(email, password, passwordConfirm);
|
||||
} else {
|
||||
result = await pbLogin(email, password);
|
||||
}
|
||||
const finishAuth = useCallback(
|
||||
async (result: Awaited<ReturnType<typeof emailLogin>>, successEmail: string) => {
|
||||
pbSaveAuth(result.token, result.record);
|
||||
try {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
@@ -58,11 +38,22 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onSuccess(email);
|
||||
onSuccess(successEmail);
|
||||
onClose();
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setPasswordConfirm("");
|
||||
},
|
||||
[onClose, onSuccess]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const normalizedEmail = email.trim();
|
||||
const result = await emailLogin(normalizedEmail);
|
||||
await finishAuth(result, normalizedEmail);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
@@ -70,6 +61,19 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleCredential = useCallback(async (credential: string) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await googleLogin(credential);
|
||||
await finishAuth(result, result.record.email);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Google 登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [finishAuth]);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
@@ -95,10 +99,10 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
</button>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{mode === "login" ? t("login") : t("register")}
|
||||
{t("login")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{mode === "login" ? t("loginHint") : t("registerHint")}
|
||||
{t("loginHint")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
@@ -113,31 +117,6 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
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">{t("password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={mode === "register" ? "至少 8 位" : "••••••••"}
|
||||
required
|
||||
minLength={mode === "register" ? 8 : 1}
|
||||
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>
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("passwordConfirm")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@@ -146,41 +125,24 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
|
||||
>
|
||||
{loading ? "处理中…" : mode === "login" ? t("login") : t("register")}
|
||||
{loading ? "处理中…" : t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
{t("noAccount")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setError(null);
|
||||
}}
|
||||
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
|
||||
>
|
||||
{t("signUpNow")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("hasAccount")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setError(null);
|
||||
}}
|
||||
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
|
||||
>
|
||||
{t("goLogin")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{GOOGLE_CLIENT_ID && (
|
||||
<>
|
||||
<div className="my-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs text-gray-400">or</span>
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<GoogleLoginButton
|
||||
disabled={loading}
|
||||
onCredential={handleGoogleCredential}
|
||||
onError={setError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
104
app/components/GoogleLoginButton.tsx
Normal file
104
app/components/GoogleLoginButton.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts?: {
|
||||
id?: {
|
||||
initialize: (options: {
|
||||
client_id: string;
|
||||
callback: (response: { credential?: string }) => void;
|
||||
}) => void;
|
||||
renderButton: (
|
||||
parent: HTMLElement,
|
||||
options: {
|
||||
theme?: "outline" | "filled_blue" | "filled_black";
|
||||
size?: "large" | "medium" | "small";
|
||||
width?: number;
|
||||
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
||||
shape?: "rectangular" | "pill" | "circle" | "square";
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const GOOGLE_SCRIPT_ID = "google-identity-services";
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
|
||||
function loadGoogleScript(): Promise<void> {
|
||||
if (typeof window === "undefined") return Promise.resolve();
|
||||
if (window.google?.accounts?.id) return Promise.resolve();
|
||||
const existing = document.getElementById(GOOGLE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener("error", () => reject(new Error("Google 登录加载失败")), { once: true });
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.id = GOOGLE_SCRIPT_ID;
|
||||
script.src = "https://accounts.google.com/gsi/client";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Google 登录加载失败"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export default function GoogleLoginButton({
|
||||
onCredential,
|
||||
onError,
|
||||
disabled = false,
|
||||
}: {
|
||||
onCredential: (credential: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!GOOGLE_CLIENT_ID || !ref.current) return;
|
||||
let cancelled = false;
|
||||
loadGoogleScript()
|
||||
.then(() => {
|
||||
if (cancelled || !ref.current || !window.google?.accounts?.id) return;
|
||||
ref.current.innerHTML = "";
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
callback: (response) => {
|
||||
if (response.credential) onCredential(response.credential);
|
||||
else onError?.("Google 登录失败");
|
||||
},
|
||||
});
|
||||
window.google.accounts.id.renderButton(ref.current, {
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
width: 304,
|
||||
text: "continue_with",
|
||||
shape: "rectangular",
|
||||
});
|
||||
setReady(true);
|
||||
})
|
||||
.catch((error) => onError?.(error instanceof Error ? error.message : "Google 登录加载失败"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onCredential, onError]);
|
||||
|
||||
if (!GOOGLE_CLIENT_ID) return null;
|
||||
|
||||
return (
|
||||
<div className={disabled ? "pointer-events-none opacity-60" : ""}>
|
||||
<div ref={ref} className="flex min-h-10 justify-center" />
|
||||
{!ready && <div className="h-10 rounded-lg border border-gray-200 dark:border-gray-700" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,9 +150,6 @@ export default function HeroSection() {
|
||||
(locale === "zh" ? "创建用户失败" : "Failed to create user")
|
||||
);
|
||||
}
|
||||
if (ensureData.is_new) {
|
||||
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
|
||||
}
|
||||
user_id = String(ensureData.user_id).trim();
|
||||
if (ensureData?.token && ensureData?.record) {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { useCallback, useState, useEffect, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useLocale } from "@/i18n/navigation";
|
||||
import {
|
||||
@@ -10,30 +10,67 @@ import {
|
||||
} from "@/app/lib/payment";
|
||||
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
import { googleLogin } from "@/app/lib/pocketbase";
|
||||
import GoogleLoginButton from "./GoogleLoginButton";
|
||||
|
||||
interface JoinModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialEmail?: string;
|
||||
/** 仅登录(已有 VIP 用户验证密码后直接登录,不跳转支付) */
|
||||
/** 仅登录(已有 VIP 用户直接邮箱登录,不跳转支付) */
|
||||
vipOnly?: boolean;
|
||||
}
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
|
||||
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 saveAuth = useCallback(async (token: string, record: Record<string, unknown>) => {
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(token, record as { id: string; email: string; [key: string]: unknown });
|
||||
try {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, record }),
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
/* local auth is already saved; backend cookie will be retried by the navbar */
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const continueAfterAuth = useCallback((userId: string) => {
|
||||
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: userId,
|
||||
return_url: returnUrl,
|
||||
channel,
|
||||
device: getDeviceFromEnv(env),
|
||||
useJoinConfig: true,
|
||||
});
|
||||
}, [locale, onClose, vipOnly]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
@@ -48,41 +85,15 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
const res = await fetch(apiUrl("/api/meetup/ensure-user"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: em, password: password || undefined }),
|
||||
body: JSON.stringify({ email: em }),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) {
|
||||
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
|
||||
}
|
||||
if (data.is_new) {
|
||||
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
|
||||
}
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(data.token, data.record);
|
||||
await fetch(apiUrl("/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,
|
||||
});
|
||||
await saveAuth(data.token, data.record);
|
||||
continueAfterAuth(String(data.user_id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
@@ -90,6 +101,21 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleCredential = useCallback(async (credential: string) => {
|
||||
if (loading) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await googleLogin(credential);
|
||||
await saveAuth(result.token, result.record);
|
||||
continueAfterAuth(result.record.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Google 登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [continueAfterAuth, loading, saveAuth]);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
@@ -111,7 +137,7 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
</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")}
|
||||
{vipOnly ? (locale === "zh" ? "邮箱登录" : "Email login") : (locale === "zh" ? "加入游牧中国" : "Join Nomad China")}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
@@ -128,18 +154,6 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
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>
|
||||
)}
|
||||
@@ -151,6 +165,16 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
{loading ? (locale === "zh" ? "处理中…" : "Processing…") : vipOnly ? (locale === "zh" ? "登录" : "Login") : (locale === "zh" ? "加入游牧中国 →" : "Join Nomad China →")}
|
||||
</button>
|
||||
</form>
|
||||
{GOOGLE_CLIENT_ID && (
|
||||
<>
|
||||
<div className="my-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs text-gray-400">or</span>
|
||||
<div className="h-px flex-1 bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<GoogleLoginButton disabled={loading} onCredential={handleGoogleCredential} onError={setError} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,7 +359,7 @@ export const helpArticles: HelpArticle[] = [
|
||||
categoryEn: "Getting Started",
|
||||
icon: "🚀",
|
||||
articles: [
|
||||
{ id: "1-1", title: "如何注册账号", titleEn: "How to register an account" },
|
||||
{ id: "1-1", title: "如何使用邮箱或 Google 登录", titleEn: "How to log in with email or Google" },
|
||||
{ id: "1-2", title: "完善个人资料", titleEn: "Complete your profile" },
|
||||
{ id: "1-3", title: "如何加入社群", titleEn: "How to join the community" },
|
||||
{ id: "1-4", title: "会员特权介绍", titleEn: "Membership benefits introduction" },
|
||||
@@ -429,7 +429,7 @@ export const helpArticles: HelpArticle[] = [
|
||||
categoryEn: "FAQ",
|
||||
icon: "❓",
|
||||
articles: [
|
||||
{ id: "6-1", title: "忘记密码怎么办", titleEn: "What if I forgot my password" },
|
||||
{ id: "6-1", title: "收不到登录入口怎么办", titleEn: "What if I cannot continue login" },
|
||||
{ id: "6-2", title: "如何联系客服", titleEn: "How to contact support" },
|
||||
{ id: "6-3", title: "账号安全问题", titleEn: "Account security issues" },
|
||||
{ id: "6-4", title: "数据隐私说明", titleEn: "Data privacy statement" },
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000";
|
||||
export const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (API_BASE.endsWith("/api") && normalizedPath.startsWith("/api/")) {
|
||||
return `${API_BASE}${normalizedPath.slice(4)}`;
|
||||
}
|
||||
return `${API_BASE}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* MinIO 客户端封装 - 上传、下载
|
||||
*/
|
||||
|
||||
import * as Minio from "minio";
|
||||
import { getMinioConfig } from "./config";
|
||||
|
||||
export function getMinioClient(): Minio.Client {
|
||||
const cfg = getMinioConfig();
|
||||
|
||||
return new Minio.Client({
|
||||
endPoint: cfg.endPoint,
|
||||
port: cfg.port,
|
||||
useSSL: cfg.useSSL,
|
||||
accessKey: cfg.accessKey,
|
||||
secretKey: cfg.secretKey,
|
||||
pathStyle: true,
|
||||
region: cfg.region,
|
||||
});
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
export async function uploadBuffer(
|
||||
buffer: Buffer,
|
||||
objectKey: string,
|
||||
contentType: string = "application/octet-stream"
|
||||
): Promise<UploadResult> {
|
||||
const cfg = getMinioConfig();
|
||||
|
||||
if (!cfg.enabled) {
|
||||
throw new Error("MinIO 存储未启用");
|
||||
}
|
||||
|
||||
const client = getMinioClient();
|
||||
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
|
||||
const url = `${cfg.publicUrl}/${objectKey}`;
|
||||
return { url, objectKey };
|
||||
}
|
||||
|
||||
export function generateObjectKey(ext: string): string {
|
||||
const cfg = getMinioConfig();
|
||||
const prefix = cfg.uploadPrefix.replace(/\/$/, "");
|
||||
return `${prefix}/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { getMinioConfig } from "@/config/services.config";
|
||||
export type { MinioConfig } from "@/config/services.config";
|
||||
@@ -1,8 +0,0 @@
|
||||
export { getMinioConfig } from "./config";
|
||||
export type { MinioConfig } from "./config";
|
||||
export {
|
||||
getMinioClient,
|
||||
uploadBuffer,
|
||||
generateObjectKey,
|
||||
} from "./client";
|
||||
export type { UploadResult } from "./client";
|
||||
@@ -1,49 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* PocketBase 认证服务 - 邮箱登录、注册
|
||||
* 认证服务 - 前端只调用 FastAPI,不直连 PocketBase。
|
||||
*/
|
||||
|
||||
import { getPocketBaseConfig } from "@/config/services.config";
|
||||
import { PB_STORAGE_KEYS } from "./constants";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
@@ -15,48 +14,37 @@ export interface AuthUser {
|
||||
export interface AuthResult {
|
||||
token: string;
|
||||
record: AuthUser;
|
||||
is_new?: boolean;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
function getPBUrl(): string {
|
||||
return getPocketBaseConfig().url;
|
||||
}
|
||||
|
||||
export async function pbLogin(
|
||||
identity: string,
|
||||
password: string
|
||||
): Promise<AuthResult> {
|
||||
const res = await fetch(apiUrl("/api/auth/login"), {
|
||||
async function authRequest(path: string, body: Record<string, unknown>): Promise<AuthResult> {
|
||||
const res = await fetch(apiUrl(path), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: identity, password }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.detail || err?.message || "登录失败");
|
||||
throw new Error(err?.detail || err?.message || "认证失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data?.token) throw new Error("登录响应异常");
|
||||
return { token: data.token, record: data.record };
|
||||
return {
|
||||
token: data.token,
|
||||
record: data.record,
|
||||
is_new: data.is_new,
|
||||
provider: data.provider,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pbRegister(
|
||||
email: string,
|
||||
password: string,
|
||||
passwordConfirm: string
|
||||
): Promise<AuthResult> {
|
||||
const res = await fetch(apiUrl("/api/auth/register"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password, passwordConfirm }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.detail || err?.message || "注册失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
return { token: data.token, record: data.record };
|
||||
export async function emailLogin(email: string): Promise<AuthResult> {
|
||||
return authRequest("/api/auth/email", { email });
|
||||
}
|
||||
|
||||
export async function googleLogin(idToken: string): Promise<AuthResult> {
|
||||
return authRequest("/api/auth/google", { id_token: idToken });
|
||||
}
|
||||
|
||||
export function pbSaveAuth(token: string, record: AuthUser): void {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { getPocketBaseConfig } from "@/config/services.config";
|
||||
export type { PocketBaseConfig } from "@/config/services.config";
|
||||
@@ -1,6 +1,6 @@
|
||||
export {
|
||||
pbLogin,
|
||||
pbRegister,
|
||||
emailLogin,
|
||||
googleLogin,
|
||||
pbSaveAuth,
|
||||
pbLogout,
|
||||
getStoredUser,
|
||||
@@ -9,4 +9,3 @@ export {
|
||||
} from "./auth";
|
||||
export type { AuthUser, AuthResult } from "./auth";
|
||||
export { PB_STORAGE_KEYS } from "./constants";
|
||||
export { getPocketBaseConfig } from "./config";
|
||||
|
||||
Reference in New Issue
Block a user