diff --git a/.gitignore b/.gitignore index 5ef6a52..ec36055 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,10 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + + +.next +.next_stale* +node_modules +dist +build diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index 74a1b81..01334e2 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -52,21 +52,48 @@ export default function JoinPage() { const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const [submitted, setSubmitted] = useState(false); + const [isVipSubmit, setIsVipSubmit] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [payRedirecting, setPayRedirecting] = useState(false); const [payEnv, setPayEnv] = useState(null); - const [payChannel, setPayChannel] = useState("alipay"); + const [payChannel, setPayChannel] = useState("wxpay"); const [authOpen, setAuthOpen] = useState(false); const [pendingSubmit, setPendingSubmit] = useState(false); + const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false); + const [checkingStatus, setCheckingStatus] = useState(true); useEffect(() => { if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") { setSubmitted(true); + setCheckingStatus(false); + return; } + const checkExisting = async () => { + const meRes = await fetch("/api/auth/me", { credentials: "include" }); + const meData = await meRes.json(); + if (!meData?.user) { + setCheckingStatus(false); + return; + } + const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" }); + const statusData = await statusRes.json(); + if (statusData?.hasRecord && statusData?.isComplete) { + setIsAlreadyRecorded(true); + setSubmitted(true); + } + setCheckingStatus(false); + }; + checkExisting(); }, []); - usePayStatusPoll(() => setSubmitted(true)); + // 轮询到支付成功时跳转到 /join/paid,避免在 /join 重复显示成功页 + usePayStatusPoll(() => { + if (typeof window === "undefined") return; + const path = window.location.pathname; + const paidPath = path.replace(/\/join\/?$/, "") + "/join/paid"; + window.location.replace(paidPath); + }); useEffect(() => { if (typeof window === "undefined") return; @@ -97,6 +124,15 @@ export default function JoinPage() { setSubmitted(true); return; } + // 再次确认 VIP,防止 handleAuthSuccess 等路径绕过 + const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); + const vipData2 = await vipRes2.json(); + if (vipData2?.vip) { + setIsVipSubmit(true); + setSubmitted(true); + setSubmitting(false); + return; + } setSubmitting(false); setPayRedirecting(true); const returnUrl = `${window.location.origin}${window.location.pathname}/paid`; @@ -127,6 +163,11 @@ export default function JoinPage() { setAuthOpen(true); return; } + const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" }); + const vipData = await vipRes.json(); + if (vipData?.vip) { + setIsVipSubmit(true); + } await doSubmit(); }; @@ -145,11 +186,11 @@ export default function JoinPage() {
-

申请已提交

+

+ {isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"} +

- 我们会尽快审核你的信息 -
- 审核通过后将通过微信邀请你入群 + {isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"}

+
加载中…
+ + ); + } + return (
diff --git a/app/[locale]/join/paid/page.tsx b/app/[locale]/join/paid/page.tsx index 5cc7873..af7224c 100644 --- a/app/[locale]/join/paid/page.tsx +++ b/app/[locale]/join/paid/page.tsx @@ -3,29 +3,127 @@ import { useState, useEffect } from "react"; import { Link } from "@/i18n/navigation"; +const DEFAULT_PASSWORD = "12345678"; + /** * 支付完成回跳页 * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页 - * 若 meetup_needs_password_change 为 true,则强制弹窗修改密码 + * 新用户支付成功后弹窗显示默认密码 12345678,并提供修改密码选项 */ export default function JoinPaidPage() { const [needPasswordChange, setNeedPasswordChange] = useState(null); - const [showChangeModal, setShowChangeModal] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); const [newPassword, setNewPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const [changeError, setChangeError] = useState(null); const [changing, setChanging] = useState(false); + const [showChangeForm, setShowChangeForm] = useState(false); + const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending"); + const [errorDetail, setErrorDetail] = useState(null); + const [countdown, setCountdown] = useState(null); useEffect(() => { - fetch("/api/meetup/need-password-change") - .then((r) => r.json()) - .then((d) => { - setNeedPasswordChange(d?.need === true); - if (d?.need === true) setShowChangeModal(true); - }) - .catch(() => setNeedPasswordChange(false)); + let cancelled = false; + + const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => { + const r = await fetch("/api/meetup/complete-order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ order_id: orderId }), + credentials: "include", + }); + const d = await r.json(); + if (!d?.ok) return { ok: false, error: d?.error || `HTTP ${r.status}` }; + + if (d?.token && d?.record) { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: d.token, record: d.record }), + credentials: "include", + }); + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(d.token, d.record); + } + window.dispatchEvent(new CustomEvent("auth:updated")); + setTimeout(() => window.dispatchEvent(new CustomEvent("auth:updated")), 500); + if (d.is_new) { + await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" }); + if (!cancelled) { + setNeedPasswordChange(true); + setShowPasswordModal(true); + } + } + return { ok: true }; + }; + + const run = async () => { + const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order=")); + const rawValue = cookie?.split("=")[1]?.trim(); + const decoded = rawValue ? decodeURIComponent(rawValue) : ""; + const orderId = decoded.split("|")[0]?.trim() || ""; + if (!orderId) { + window.location.replace(`/${window.location.pathname.split("/")[1] || "zh"}/join`); + return; + } + + const maxClientRetries = 5; + const retryDelays = [2000, 3000, 4000, 5000, 6000]; + let lastError = ""; + for (let i = 0; i <= maxClientRetries; i++) { + if (cancelled) return; + try { + const result = await tryComplete(orderId); + if (result.ok) { + document.cookie = "join_pay_order=; path=/; max-age=0"; + if (!cancelled) setCompleteStatus("success"); + return; + } + lastError = result.error || "unknown"; + } catch (e) { + lastError = e instanceof Error ? e.message : "network error"; + } + if (i < maxClientRetries) { + await new Promise((r) => setTimeout(r, retryDelays[i])); + } + } + if (!cancelled) { + setCompleteStatus("error"); + setErrorDetail(lastError); + } + + fetch("/api/meetup/need-password-change") + .then((res) => res.json()) + .then((d) => { + if (cancelled) return; + setNeedPasswordChange(d?.need === true); + if (d?.need === true) setShowPasswordModal(true); + }) + .catch(() => { if (!cancelled) setNeedPasswordChange(false); }); + }; + run(); + + return () => { cancelled = true; }; }, []); + const COUNTDOWN_SECONDS = 5; + useEffect(() => { + if (completeStatus !== "success" || showPasswordModal) return; + setCountdown(COUNTDOWN_SECONDS); + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev == null || prev <= 1) { + clearInterval(timer); + const locale = window.location.pathname.split("/")[1] || "zh"; + window.location.replace(`/${locale}`); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [completeStatus, showPasswordModal]); + const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); setChangeError(null); @@ -49,7 +147,7 @@ export default function JoinPaidPage() { if (!res.ok || !data?.ok) { throw new Error(data?.error || "修改失败"); } - setShowChangeModal(false); + setShowPasswordModal(false); } catch (err) { setChangeError(err instanceof Error ? err.message : "修改失败"); } finally { @@ -57,6 +155,14 @@ export default function JoinPaidPage() { } }; + const handleCopyPassword = async () => { + try { + await navigator.clipboard.writeText(DEFAULT_PASSWORD); + } catch { + /* ignore */ + } + }; + return ( <>
@@ -64,12 +170,30 @@ export default function JoinPaidPage() {
-

申请已提交

+

+ {completeStatus === "pending" ? "处理中…" : completeStatus === "success" ? "支付成功" : "处理异常"} +

- 我们会尽快审核你的信息 -
- 审核通过后将通过微信邀请你入群 + {completeStatus === "success" + ? countdown != null + ? `${countdown} 秒后返回首页` + : "感谢您的支持" + : completeStatus === "error" + ? "请稍后刷新页面重试" + : "正在确认支付状态…"}

+ {completeStatus === "error" && errorDetail && ( +

{errorDetail}

+ )} + {completeStatus === "error" && ( + + )}
- {showChangeModal && ( + {showPasswordModal && (
-

请修改默认密码

+

您的登录密码

- 为保障账号安全,请立即设置新密码(至少 8 位) + 请妥善保存以下默认密码,用于登录本网站(游牧中国)

-
- { setNewPassword(e.target.value); setChangeError(null); }} - placeholder="新密码" - minLength={8} - required - className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800" - /> - { 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 &&

{changeError}

} +
+ + {DEFAULT_PASSWORD} + - +
+ {!showChangeForm ? ( + + ) : ( +
+ { 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" + /> + { 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 &&

{changeError}

} +
+ + +
+
+ )} +
)} diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts index 2a46930..35e92ef 100644 --- a/app/api/auth/change-password/route.ts +++ b/app/api/auth/change-password/route.ts @@ -3,6 +3,7 @@ */ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; const COOKIE_NAME = "pb_session"; const COOKIE_NEEDS_PW = "meetup_needs_password_change"; @@ -38,24 +39,35 @@ export async function POST(request: NextRequest) { } const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, { + const patchBody = { password: newPassword, passwordConfirm }; + + let res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}`, }, - body: JSON.stringify({ - password: newPassword, - passwordConfirm: passwordConfirm, - }), + body: JSON.stringify(patchBody), }); + if (!res.ok) { + const adminToken = await getAdminToken(); + if (adminToken) { + res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify(patchBody), + }); + } + } + if (!res.ok) { const err = await res.json().catch(() => ({})); - return NextResponse.json( - { ok: false, error: err?.message || "修改失败" }, - { status: 400 } - ); + const msg = err?.message || (err?.data && typeof err.data === "object" && Object.values(err.data)[0] && (Object.values(err.data)[0] as { message?: string })?.message) || "修改失败"; + return NextResponse.json({ ok: false, error: msg }, { status: 400 }); } const r = NextResponse.json({ ok: true }); diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 7e07949..ededd1c 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,15 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthCookieDomain } from "@/config/domain.config"; - -const COOKIE_NAME = "pb_session"; +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; export async function POST(request: NextRequest) { - const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, "", { - ...(domain && { domain }), - path: "/", - maxAge: 0, - }); + res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 53952fe..12907e8 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,13 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; -import { getPocketBaseConfig } from "@/config/services.config"; -import { getAuthCookieDomain } from "@/config/domain.config"; +import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; const COOKIE_NAME = "pb_session"; const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +async function checkVip(userId: string): Promise { + 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; - const domain = getAuthCookieDomain(request); if (!cookie) { return NextResponse.json({ ok: true, user: null }); } @@ -17,12 +39,14 @@ export async function GET(request: NextRequest) { if (!token || !userId) return NextResponse.json({ ok: true, user: null }); const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/users/refresh`, { + const res = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-refresh`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", }); if (!res.ok) { const r = NextResponse.json({ ok: true, user: null }); - r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 }); + r.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 }); return r; } const data = await res.json(); @@ -35,13 +59,13 @@ export async function GET(request: NextRequest) { email: newRecord.email ?? email, }); const encoded = Buffer.from(newPayload, "utf-8").toString("base64url"); + const vip = await checkVip(newRecord.id); const r = NextResponse.json({ ok: true, - user: { id: newRecord.id, email: newRecord.email ?? email }, + user: { id: newRecord.id, email: newRecord.email ?? email, vip }, token: newToken, }); r.cookies.set(COOKIE_NAME, encoded, { - ...(domain && { domain }), path: "/", maxAge: COOKIE_MAX_AGE, secure: process.env.NODE_ENV === "production", @@ -50,9 +74,11 @@ export async function GET(request: NextRequest) { }); return r; } + const uid = data.record?.id ?? userId; + const vip = uid ? await checkVip(uid) : false; return NextResponse.json({ ok: true, - user: { id: data.record?.id ?? userId, email: data.record?.email ?? email }, + user: { id: uid, email: data.record?.email ?? email, vip }, token: data.token, }); } catch { diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts index 6fa0780..4c1dac3 100644 --- a/app/api/auth/sync-session/route.ts +++ b/app/api/auth/sync-session/route.ts @@ -1,8 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getAuthCookieDomain } from "@/config/domain.config"; - -const COOKIE_NAME = "pb_session"; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出 +import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie"; export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})); @@ -19,15 +16,7 @@ export async function POST(request: NextRequest) { }); const encoded = Buffer.from(payload, "utf-8").toString("base64url"); - const domain = getAuthCookieDomain(request); const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE_NAME, encoded, { - ...(domain && { domain }), - path: "/", - maxAge: COOKIE_MAX_AGE, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - httpOnly: true, - }); + res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record); return res; } diff --git a/app/api/join/route.ts b/app/api/join/route.ts index 113adc4..9e7053e 100644 --- a/app/api/join/route.ts +++ b/app/api/join/route.ts @@ -1,40 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; -const COOKIE_NAME = "pb_session"; +import { COOKIE_NAME } from "@/app/lib/auth-cookie"; -function getOrCreateUserId(request: NextRequest): string { +function getUserIdFromCookie(request: NextRequest): string | null { const cookie = request.cookies.get(COOKIE_NAME)?.value; - if (cookie) { - try { - const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); - if (payload?.userId) return payload.userId; - } catch { - /* ignore */ - } + if (!cookie) return null; + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + return payload?.userId ?? null; + } catch { + return null; } - return `user${Date.now()}`; -} - -async function getAdminToken(): Promise { - const email = process.env.POCKETBASE_EMAIL; - const password = process.env.POCKETBASE_PASSWORD; - if (!email || !password) return null; - - const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!res.ok) return null; - const data = await res.json(); - return data?.token ?? null; } export async function POST(request: NextRequest) { try { - const userId = getOrCreateUserId(request); + const userId = getUserIdFromCookie(request); + if (!userId) { + return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 }); + } const body = await request.json(); const formData = body && typeof body === "object" ? body : {}; @@ -73,27 +59,55 @@ export async function POST(request: NextRequest) { const pb = getPocketBaseConfig(); const collection = pb.joinCollection; + const base = pb.url.replace(/\/$/, ""); - const doCreate = async (authToken?: string) => { + const doUpsert = async (authToken: string) => { const headers: Record = { "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, }; - if (authToken) headers["Authorization"] = authToken; - return fetch(`${pb.url}/api/collections/${collection}/records`, { + const filter = `user_id="${userId}"`; + const listRes = await fetch( + `${base}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}&perPage=1`, + { headers: { Authorization: `Bearer ${authToken}` }, cache: "no-store" } + ); + const listData = await listRes.json().catch(() => ({})); + const existing = (listData?.items ?? [])[0]; + + if (existing?.id) { + const patchBody: Record = { + nickname: record.nickname, + occupation: record.occupation, + reason: record.reason, + single: record.single, + wechatId: record.wechatId, + gender: record.gender, + education: record.education, + gradYear: record.gradYear, + phone: record.phone, + }; + if ("media" in record) patchBody.media = record.media as string; + return fetch(`${base}/api/collections/${collection}/records/${existing.id}`, { + method: "PATCH", + headers, + body: JSON.stringify(patchBody), + }); + } + return fetch(`${base}/api/collections/${collection}/records`, { method: "POST", headers, body: JSON.stringify(record), }); }; - let res = await doCreate(); - - if (res.status === 403) { - const token = await getAdminToken(); - if (token) res = await doCreate(token); + const token = await getAdminToken(); + if (!token) { + return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 }); } + const res = await doUpsert(token); + if (!res.ok) { const errText = await res.text(); console.error("PocketBase create error:", res.status, errText); diff --git a/app/api/join/status/route.ts b/app/api/join/status/route.ts new file mode 100644 index 0000000..e1ee313 --- /dev/null +++ b/app/api/join/status/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; + +import { COOKIE_NAME } from "@/app/lib/auth-cookie"; + +function getUserIdFromCookie(request: NextRequest): string | null { + const cookie = request.cookies.get(COOKIE_NAME)?.value; + if (!cookie) return null; + try { + const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8")); + return payload?.userId ?? null; + } catch { + return null; + } +} + +const REQUIRED_FIELDS = ["nickname", "occupation", "reason", "single", "wechatId", "gender", "phone"]; + +function isRecordComplete(record: Record): 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); + return NextResponse.json({ ok: true, hasRecord: true, isComplete }); + } catch { + return NextResponse.json({ ok: true, hasRecord: false, isComplete: false }); + } +} diff --git a/app/api/meetup/_paymentApi.ts b/app/api/meetup/_paymentApi.ts new file mode 100644 index 0000000..50d6a1b --- /dev/null +++ b/app/api/meetup/_paymentApi.ts @@ -0,0 +1,81 @@ +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); +} + +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) { + try { + const res = await fetch(`${apiUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + }); + 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 }; + } catch (error) { + lastError = error; + } + } + + if (lastResponse) { + return lastResponse; + } + + throw lastError instanceof Error ? lastError : new Error("Meetup API request failed"); +} diff --git a/app/api/meetup/check-user/route.ts b/app/api/meetup/check-user/route.ts new file mode 100644 index 0000000..5ca881c --- /dev/null +++ b/app/api/meetup/check-user/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { postMeetupApi } from "../_paymentApi"; + +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 }); + return NextResponse.json(data, { status }); + } catch (error) { + console.error("check-user error:", error); + return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 }); + } +} diff --git a/app/api/meetup/complete-order/route.ts b/app/api/meetup/complete-order/route.ts new file mode 100644 index 0000000..bb53064 --- /dev/null +++ b/app/api/meetup/complete-order/route.ts @@ -0,0 +1,203 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPocketBaseConfig, getPaymentConfig, SITE_ID } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; + +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 queryZpayPaid(orderId: string): Promise { + const config = getPaymentConfig(); + if (!config.apiUrl) { + console.error("queryZpayPaid: PAYMENT_API_URL not configured"); + return false; + } + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + const url = `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`; + const res = await fetch(url, { cache: "no-store", signal: controller.signal }) + .finally(() => clearTimeout(timeout)); + const data = await res.json().catch(() => ({})); + if (!data?.paid) { + console.warn(`queryZpayPaid: not paid yet, order_id=${orderId}, response=`, JSON.stringify(data)); + } + return !!data?.paid; + } catch (e) { + console.error(`queryZpayPaid: fetch error, order_id=${orderId}`, e); + return false; + } +} + +async function ensureSiteVip( + base: string, + adminToken: string, + userId: string, + orderId: string, +): Promise { + 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 { + 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 zpayPaid = await queryZpayPaid(orderId); + if (!zpayPaid) { + 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 }); + } +} diff --git a/app/api/meetup/ensure-user/route.ts b/app/api/meetup/ensure-user/route.ts index 52b849c..4399189 100644 --- a/app/api/meetup/ensure-user/route.ts +++ b/app/api/meetup/ensure-user/route.ts @@ -1,110 +1,30 @@ -/** - * 加入游牧中国:确保用户存在 - * 新用户:用默认密码 123456789 创建,支付成功后需修改 - * 老用户:验证密码后登录 - */ import { NextRequest, NextResponse } from "next/server"; -import { getPocketBaseConfig } from "@/config/services.config"; +import { postMeetupApi } from "../_paymentApi"; -const DEFAULT_PASSWORD = "123456789"; const COOKIE_NEEDS_PW = "meetup_needs_password_change"; -async function getAdminToken(): Promise { - const email = process.env.POCKETBASE_EMAIL; - const password = process.env.POCKETBASE_PASSWORD; - if (!email || !password) return null; - const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!res.ok) return null; - const data = await res.json(); - return data?.token ?? null; -} - export async function POST(request: NextRequest) { try { const body = await request.json().catch(() => ({})); const email = String(body?.email || "").trim(); const password = String(body?.password || "").trim(); if (!email) { - return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 }); + return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 }); } - const pb = getPocketBaseConfig(); - const tryLogin = async (pwd: string) => { - const res = await fetch(`${pb.url}/api/collections/users/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password: pwd }), - }); - return res; - }; - - let loginRes = await tryLogin(password || DEFAULT_PASSWORD); - if (loginRes.ok) { - const data = await loginRes.json(); - const res = NextResponse.json({ - ok: true, - user_id: data.record?.id, - token: data.token, - record: data.record, - is_new: false, - }); - return res; - } - - const errData = await loginRes.json().catch(() => ({})); - const isNotFound = loginRes.status === 400 && (errData?.message?.includes("Invalid") || errData?.message?.includes("identity")); - if (!isNotFound && password) { - return NextResponse.json({ ok: false, error: "密码错误" }, { status: 400 }); - } - - const adminToken = await getAdminToken(); - if (!adminToken) { - return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 }); - } - - const createRes = await fetch(`${pb.url}/api/collections/users/records`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ - email, - password: DEFAULT_PASSWORD, - passwordConfirm: DEFAULT_PASSWORD, - }), + const { status, data } = await postMeetupApi(request, "/api/meetup/ensure-user", { + email, + password: password || undefined, }); - if (!createRes.ok) { - const createErr = await createRes.json().catch(() => ({})); - if (createRes.status === 400 && createErr?.data?.email?.message?.includes("already")) { - return NextResponse.json({ ok: false, error: "该邮箱已注册,请输入密码登录" }, { status: 400 }); - } - return NextResponse.json({ ok: false, error: createErr?.message || "创建账号失败" }, { status: 500 }); - } + const payload = typeof data === "object" && data !== null ? data as Record : null; - const newUser = await createRes.json(); - const finalLogin = await tryLogin(DEFAULT_PASSWORD); - if (!finalLogin.ok) { - return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 }); + const nextRes = NextResponse.json(data, { status }); + if (status >= 200 && status < 300 && payload?.is_new) { + nextRes.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 }); } - const authData = await finalLogin.json(); - - const res = NextResponse.json({ - ok: true, - user_id: authData.record?.id, - token: authData.token, - record: authData.record, - is_new: true, - }); - res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 }); - return res; - } catch (e) { - console.error("ensure-user error:", e); - return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 }); + return nextRes; + } catch (error) { + console.error("ensure-user error:", error); + return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 }); } } diff --git a/app/api/meetup/set-needs-password-change/route.ts b/app/api/meetup/set-needs-password-change/route.ts new file mode 100644 index 0000000..d0ec74b --- /dev/null +++ b/app/api/meetup/set-needs-password-change/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +const COOKIE_NEEDS_PW = "meetup_needs_password_change"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 }); + return res; +} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts index bbb3cd7..1d61181 100644 --- a/app/api/pay/route.ts +++ b/app/api/pay/route.ts @@ -7,7 +7,7 @@ async function createPayOrder( return_url: string, total_fee?: number, type?: string, - channel = "alipay" + channel = "alipay" ) { const config = getPaymentConfig(); if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL"); @@ -81,7 +81,7 @@ export async function POST(request: NextRequest) { .map(([k, v]) => ``) .join("\n"); const orderId = String((params as Record)?.out_trade_no || "").trim(); - const html = `跳转支付...

正在跳转到支付页面...

${inputs}
`; + const html = `跳转支付
${inputs}
`; const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); if (orderId) { res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 }); diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts index 9bdc5f9..75046bb 100644 --- a/app/api/vip/check/route.ts +++ b/app/api/vip/check/route.ts @@ -1,23 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getPocketBaseConfig, SITE_ID } from "@/config/services.config"; +import { getAdminToken } from "@/app/lib/pocketbase/admin"; const COOKIE_NAME = "pb_session"; -async function getAdminToken(): Promise { - const email = process.env.POCKETBASE_EMAIL; - const password = process.env.POCKETBASE_PASSWORD; - if (!email || !password) return null; - const pb = getPocketBaseConfig(); - const res = await fetch(`${pb.url}/api/admins/auth-with-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identity: email, password }), - }); - if (!res.ok) return null; - const data = await res.json(); - return data?.token ?? null; -} - export async function GET(request: NextRequest) { const cookie = request.cookies.get(COOKIE_NAME)?.value; if (!cookie) { diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index c4f4da4..49a839d 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -1,9 +1,16 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { Link, useLocale } from "@/i18n/navigation"; import { useTranslation } from "@/i18n/navigation"; import JoinModal from "./JoinModal"; +import { + getRecommendedChannel, + mustUseWxPay, + redirectToPay, + getDeviceFromEnv, +} from "@/app/lib/payment"; +import type { PayChannel, PayEnv } from "@/app/lib/payment"; const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"]; const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"]; @@ -30,15 +37,144 @@ const features = [ { icon: "💬", key: "feature5" as const }, ]; +function isPrivateDevHost(hostname: string): boolean { + if (!hostname) return false; + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { + return true; + } + if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) { + return true; + } + const match = hostname.match(/^172\.(\d{1,2})\./); + if (!match) return false; + const secondOctet = Number(match[1]); + return secondOctet >= 16 && secondOctet <= 31; +} + +function buildPendingUserId(email: string): string { + return ( + "pending_" + + Array.from(new TextEncoder().encode(email)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + ); +} + export default function HeroSection() { const { t: tHero } = useTranslation("hero"); const locale = useLocale(); const [email, setEmail] = useState(""); const [joinModalOpen, setJoinModalOpen] = useState(false); + const [joinModalVipOnly, setJoinModalVipOnly] = useState(false); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + useEffect(() => { + const checkAuth = async () => { + try { + const res = await fetch("/api/auth/me", { credentials: "include" }); + const data = await res.json(); + setIsLoggedIn(!!data?.user); + } catch { + setIsLoggedIn(false); + } + }; + checkAuth(); + const onAuth = () => checkAuth(); + window.addEventListener("auth:updated", onAuth); + return () => window.removeEventListener("auth:updated", onAuth); + }, []); + + const handleCheckAndProceed = useCallback( + async (em: string) => { + if (checking) return; + const trimmed = em.trim(); + if (!trimmed) { + setError(locale === "zh" ? "请输入邮箱" : "Please enter your email"); + return; + } + setError(null); + setChecking(true); + try { + const checkRes = await fetch("/api/meetup/check-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed }), + credentials: "include", + }); + const checkData = await checkRes.json(); + if (!checkRes.ok || !checkData?.ok) { + throw new Error(checkData?.error || checkData?.detail || (locale === "zh" ? "操作失败" : "Operation failed")); + } + if (checkData.exists) { + if (checkData.vip) { + setJoinModalOpen(true); + setJoinModalVipOnly(true); + return; + } + setJoinModalVipOnly(false); + setJoinModalOpen(true); + return; + } + // 新用户:支付成功后才创建,用 pending_hex(email) 作为 user_id + const shouldPrecreateUser = + typeof window !== "undefined" && + isPrivateDevHost(window.location.hostname); + let user_id = buildPendingUserId(trimmed); + if (shouldPrecreateUser) { + const ensureRes = await fetch("/api/meetup/ensure-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed }), + credentials: "include", + }); + const ensureData = await ensureRes.json(); + if (!ensureRes.ok || !ensureData?.ok || !ensureData?.user_id) { + throw new Error( + ensureData?.error || + ensureData?.detail || + (locale === "zh" ? "创建用户失败" : "Failed to create user") + ); + } + user_id = String(ensureData.user_id).trim(); + if (ensureData?.token && ensureData?.record) { + await fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: ensureData.token, record: ensureData.record }), + credentials: "include", + }); + } + } + const returnUrl = + typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid"; + const env: PayEnv = + typeof window !== "undefined" + ? navigator.userAgent.includes("MicroMessenger") + ? "wechat" + : "pc" + : "pc"; + const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); + redirectToPay({ + user_id, + return_url: returnUrl, + channel, + device: getDeviceFromEnv(env), + useJoinConfig: true, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "操作失败"); + } finally { + setChecking(false); + } + }, + [checking, locale] + ); const handleJoinClick = (e: React.FormEvent) => { e.preventDefault(); - setJoinModalOpen(true); + handleCheckAndProceed(email); }; return ( @@ -146,29 +282,39 @@ export default function HeroSection() {
-
- setEmail(e.target.value)} - placeholder={tHero("emailPlaceholder")} - className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3" - /> - -

- {locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"} -

-
+ {!isLoggedIn ? ( +
+ { setEmail(e.target.value); setError(null); }} + placeholder={tHero("emailPlaceholder")} + className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3" + /> + {error && ( +

{error}

+ )} + +
+ ) : ( +
+

+ {locale === "zh" ? "您已登录,可前往社区参与互动" : "You are logged in"} +

+
+ )}
setJoinModalOpen(false)} + onClose={() => { setJoinModalOpen(false); setJoinModalVipOnly(false); }} initialEmail={email.trim()} + vipOnly={joinModalVipOnly} />
diff --git a/app/components/JoinModal.tsx b/app/components/JoinModal.tsx index 2d7055c..42c3140 100644 --- a/app/components/JoinModal.tsx +++ b/app/components/JoinModal.tsx @@ -15,9 +15,11 @@ interface JoinModalProps { isOpen: boolean; onClose: () => void; initialEmail?: string; + /** 仅登录(已有 VIP 用户验证密码后直接登录,不跳转支付) */ + vipOnly?: boolean; } -export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinModalProps) { +export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly = false }: JoinModalProps) { const locale = useLocale(); const [email, setEmail] = useState(initialEmail); const [password, setPassword] = useState(""); @@ -34,6 +36,7 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo const handleSubmit = async (e: FormEvent) => { e.preventDefault(); + if (loading) return; const em = email.trim(); if (!em) { setError(locale === "zh" ? "请输入邮箱" : "Please enter your email"); @@ -52,12 +55,21 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo if (!res.ok || !data?.ok) { throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed")); } + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, data.record); await fetch("/api/auth/sync-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: data.token, record: data.record }), credentials: "include", }); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:updated")); + } + if (vipOnly) { + onClose(); + return; + } const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid"; const env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc"; const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env); @@ -96,11 +108,8 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo

- {locale === "zh" ? "加入游牧中国" : "Join Nomad China"} + {vipOnly ? (locale === "zh" ? "验证密码登录" : "Verify password to login") : (locale === "zh" ? "加入游牧中国" : "Join Nomad China")}

-

- {locale === "zh" ? "新用户将自动注册,老用户请输入密码" : "New users auto-register; existing users enter password"} -

@@ -118,13 +127,13 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo
{ setPassword(e.target.value); setError(null); }} - placeholder={locale === "zh" ? "新用户可留空" : "Leave empty for new users"} + 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" />
@@ -136,13 +145,9 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo disabled={loading} className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70" > - {loading ? (locale === "zh" ? "处理中…" : "Processing…") : (locale === "zh" ? "加入游牧中国 →" : "Join Nomad China →")} + {loading ? (locale === "zh" ? "处理中…" : "Processing…") : vipOnly ? (locale === "zh" ? "登录" : "Login") : (locale === "zh" ? "加入游牧中国 →" : "Join Nomad China →")}
- -

- {locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"} -

diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 042a125..cdd6c8f 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -7,6 +7,7 @@ import { getNavLinks } from "@/config"; import AuthModal from "./AuthModal"; import ThemeToggle from "./ThemeToggle"; import SiteMenu from "./SiteMenu"; +import UserAvatar from "./UserAvatar"; export default function NavbarComponent() { const { t } = useTranslation("common"); @@ -18,33 +19,42 @@ export default function NavbarComponent() { const [menuOpen, setMenuOpen] = useState(false); const [authOpen, setAuthOpen] = useState(false); const [userEmail, setUserEmail] = useState(null); + const [userVip, setUserVip] = useState(false); const navLinks = getNavLinks(); - useEffect(() => { - let mounted = true; - (async () => { - try { - const res = await fetch("/api/auth/me", { credentials: "include" }); - const data = await res.json(); - if (!mounted) return; - if (data?.user && data?.token) { - const { pbSaveAuth } = await import("@/app/lib/pocketbase"); - pbSaveAuth(data.token, { id: data.user.id, email: data.user.email }); - setUserEmail(data.user.email); - return; - } - } catch { - /* ignore */ + const fetchAuth = useCallback(async () => { + try { + const res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); + const data = await res.json(); + if (data?.user && data?.token) { + const { pbSaveAuth } = await import("@/app/lib/pocketbase"); + pbSaveAuth(data.token, { id: data.user.id, email: data.user.email }); + setUserEmail(data.user.email); + setUserVip(!!data.user.vip); + return; } - if (mounted) setUserEmail(getStoredUserEmail()); - })(); - return () => { mounted = false; }; + } catch { + /* ignore */ + } + setUserEmail(getStoredUserEmail()); + setUserVip(false); }, []); + useEffect(() => { + fetchAuth(); + }, [fetchAuth]); + + useEffect(() => { + const onAuthUpdated = () => fetchAuth(); + window.addEventListener("auth:updated", onAuthUpdated); + return () => window.removeEventListener("auth:updated", onAuthUpdated); + }, [fetchAuth]); + const handleLogout = useCallback(async () => { await pbLogout(); setUserEmail(null); + setUserVip(false); }, []); const isActive = useCallback((href: string) => { @@ -114,12 +124,7 @@ export default function NavbarComponent() { {userEmail} - + { setUserEmail(null); setUserVip(false); }} /> ) : null} ) : null} diff --git a/app/components/UserAvatar.tsx b/app/components/UserAvatar.tsx new file mode 100644 index 0000000..9a8cfe2 --- /dev/null +++ b/app/components/UserAvatar.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "@/i18n/navigation"; +import { pbLogout } from "@/app/lib/pocketbase"; + +function getAvatarLetter(email: string): string { + const local = email.split("@")[0]; + if (!local) return "?"; + return local.slice(0, 1).toUpperCase(); +} + +interface UserAvatarProps { + email: string; + isVip?: boolean; + onLogout?: () => void; +} + +/** 圆形头像 + VIP 标识 + 点击展开退出按钮 */ +export default function UserAvatar({ email, isVip = false, onLogout }: UserAvatarProps) { + const { t } = useTranslation("common"); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleLogout = async () => { + await pbLogout(); + setOpen(false); + onLogout?.(); + }; + + return ( +
+ + {isVip && ( + + VIP + + )} + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 13e5ded..8d0420e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,7 @@ -@import "tailwindcss"; +@import "tailwindcss" source(none); +@source "./"; +@source not "./favicon.ico"; +@source "../config"; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/app/lib/auth-cookie.ts b/app/lib/auth-cookie.ts new file mode 100644 index 0000000..816977e --- /dev/null +++ b/app/lib/auth-cookie.ts @@ -0,0 +1,33 @@ +/** + * 认证 Cookie 配置 + * 生产环境设置 AUTH_COOKIE_DOMAIN 实现跨子域 SSO(如 .hackrobot.cn) + * 本地开发不设置 domain,避免 IP 访问时 cookie 失效 + */ + +const COOKIE_NAME = "pb_session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +export { COOKIE_NAME, COOKIE_MAX_AGE }; + +export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string { + const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || ""; + return host.split(",")[0].trim().replace(/:\d+$/, ""); +} + +export function getCookieOptions(clear = false, requestHost?: string) { + const isProd = process.env.NODE_ENV === "production"; + const domain = process.env.AUTH_COOKIE_DOMAIN?.trim(); + const opts: Record = { + path: "/", + maxAge: clear ? 0 : COOKIE_MAX_AGE, + secure: isProd, + sameSite: "lax" as const, + httpOnly: true, + }; + if (domain && isProd) { + opts.domain = domain; + } else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) { + opts.domain = requestHost; + } + return opts; +} diff --git a/app/lib/env.ts b/app/lib/env.ts index 15a789c..8c5f626 100644 --- a/app/lib/env.ts +++ b/app/lib/env.ts @@ -2,4 +2,4 @@ const isDev = process.env.NODE_ENV === "development"; export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || - (isDev ? "http://localhost:3000" : "https://nomadcna.cn"); + (isDev ? "http://localhost:3001" : "https://nomadcna.cn"); diff --git a/app/lib/payEnv.ts b/app/lib/payEnv.ts index d789878..1886e8d 100644 --- a/app/lib/payEnv.ts +++ b/app/lib/payEnv.ts @@ -30,8 +30,7 @@ export function getPayEnv(): PayEnv { } export function getRecommendedChannel(env: PayEnv): PayChannel { - if (env === "wechat") return "wxpay"; - return "alipay"; + return "wxpay"; // 默认微信支付(PC/H5/微信内均优先) } export function mustUseWxPay(env: PayEnv): boolean { diff --git a/app/lib/payment/usePayStatusPoll.ts b/app/lib/payment/usePayStatusPoll.ts index b3a29f5..5f0ed4f 100644 --- a/app/lib/payment/usePayStatusPoll.ts +++ b/app/lib/payment/usePayStatusPoll.ts @@ -58,7 +58,6 @@ export function usePayStatusPoll(onPaid: () => void): boolean { const data = await res.json(); if (data?.ok && data?.paid) { clearInterval(timer); - clearCookie(COOKIE_NAME); setPolling(false); onPaidRef.current(); } diff --git a/app/lib/pocketbase/admin.ts b/app/lib/pocketbase/admin.ts new file mode 100644 index 0000000..5fa5173 --- /dev/null +++ b/app/lib/pocketbase/admin.ts @@ -0,0 +1,44 @@ +import { getPocketBaseConfig } from "@/config/services.config"; + +let cachedToken: { token: string; expiresAt: number } | null = null; +const TOKEN_TTL_MS = 10 * 60 * 1000; + +/** + * PocketBase v0.23+ 使用 _superusers 集合替代旧 /api/admins 端点。 + * 此函数依次尝试新旧两个端点,兼容所有版本。 + */ +export async function getAdminToken(): Promise { + 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; +} diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts index c1d3c66..ffd1de2 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -73,10 +73,11 @@ export async function pbLogout(): Promise { localStorage.removeItem(PB_STORAGE_KEYS.token); localStorage.removeItem(PB_STORAGE_KEYS.user); try { - await fetch("/api/auth/logout", { method: "POST" }); + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); } catch { /* ignore */ } + window.dispatchEvent(new CustomEvent("auth:updated")); } export function getStoredUserEmail(): string | null { diff --git a/config/domain.config.ts b/config/domain.config.ts index 40654c2..98a1943 100644 --- a/config/domain.config.ts +++ b/config/domain.config.ts @@ -8,20 +8,9 @@ const isDev = process.env.NODE_ENV === "development"; export const ROOT_DOMAIN = process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn"; -export const AUTH_COOKIE_DOMAIN = - process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`; - -/** 根据请求 host 返回 Cookie domain。localhost 时不设 domain,生产环境用根域实现跨站 SSO */ -export function getAuthCookieDomain(request: { headers: { get: (name: string) => string | null } }): string | undefined { - const envDomain = process.env.AUTH_COOKIE_DOMAIN; - if (envDomain) return envDomain; - const host = request.headers.get("host") ?? ""; - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return undefined; - return AUTH_COOKIE_DOMAIN; -} - +/** 支付 API 默认地址(本地可用 PAYMENT_API_URL 覆盖) */ export const DEFAULT_PAYMENT_API_URL = isDev - ? "http://127.0.0.1:8700" + ? "http://127.0.0.1:8007" : `https://api.${ROOT_DOMAIN}`; export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`; diff --git a/dev-server.log b/dev-server.log new file mode 100644 index 0000000..b18929e Binary files /dev/null and b/dev-server.log differ diff --git a/next.config.ts b/next.config.ts index aecebbc..2c41525 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,7 @@ const nextConfig: NextConfig = { "172.27.128.1:3000", "192.168.41.222", "192.168.41.222:3000", + "192.168.41.222:3001", "http://localhost:3000", "http://127.0.0.1:3000", "http://172.27.128.1:3000", diff --git a/package.json b/package.json index 14f069d..54026d1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -H 0.0.0.0", + "dev": "next dev -p 3001 -H 0.0.0.0", "build": "next build", "start": "next start", "lint": "eslint"