diff --git a/app/[locale]/join/page.tsx b/app/[locale]/join/page.tsx index 7e59f9d..5f1b849 100644 --- a/app/[locale]/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -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 ( -
-
加载中…
-
- ); - } - return (
diff --git a/app/[locale]/join/paid/page.tsx b/app/[locale]/join/paid/page.tsx index 256911c..7c04e49 100644 --- a/app/[locale]/join/paid/page.tsx +++ b/app/[locale]/join/paid/page.tsx @@ -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(null); - 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); @@ -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() {
- - {showPasswordModal && ( -
-
-

您的登录密码

-

- 请妥善保存以下默认密码,用于登录本网站(游牧中国) -

-
- - {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 deleted file mode 100644 index 35e92ef..0000000 --- a/app/api/auth/change-password/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts deleted file mode 100644 index ededd1c..0000000 --- a/app/api/auth/logout/route.ts +++ /dev/null @@ -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); - return res; -} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts deleted file mode 100644 index 12907e8..0000000 --- a/app/api/auth/me/route.ts +++ /dev/null @@ -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 { - 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 }); - } -} diff --git a/app/api/auth/sync-session/route.ts b/app/api/auth/sync-session/route.ts deleted file mode 100644 index 4c1dac3..0000000 --- a/app/api/auth/sync-session/route.ts +++ /dev/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); - return res; -} diff --git a/app/api/geo/china/route.ts b/app/api/geo/china/route.ts deleted file mode 100644 index debfbb4..0000000 --- a/app/api/geo/china/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/app/api/join/route.ts b/app/api/join/route.ts deleted file mode 100644 index 9e7053e..0000000 --- a/app/api/join/route.ts +++ /dev/null @@ -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 = { - 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)["media"] = mediaUrl; - } - - const pb = getPocketBaseConfig(); - const collection = pb.joinCollection; - const base = pb.url.replace(/\/$/, ""); - - const doUpsert = async (authToken: string) => { - const headers: Record = { - "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 = { - 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 } - ); - } -} diff --git a/app/api/join/status/route.ts b/app/api/join/status/route.ts deleted file mode 100644 index e1ee313..0000000 --- a/app/api/join/status/route.ts +++ /dev/null @@ -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): 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 deleted file mode 100644 index 9b2da19..0000000 --- a/app/api/meetup/_paymentApi.ts +++ /dev/null @@ -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"); -} diff --git a/app/api/meetup/check-user/route.ts b/app/api/meetup/check-user/route.ts deleted file mode 100644 index a4dc789..0000000 --- a/app/api/meetup/check-user/route.ts +++ /dev/null @@ -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) : {}; - 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 } - ); - } -} diff --git a/app/api/meetup/complete-order/route.ts b/app/api/meetup/complete-order/route.ts deleted file mode 100644 index d52d35d..0000000 --- a/app/api/meetup/complete-order/route.ts +++ /dev/null @@ -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 { - 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 { - 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 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 }); - } -} diff --git a/app/api/meetup/ensure-user/route.ts b/app/api/meetup/ensure-user/route.ts deleted file mode 100644 index 4399189..0000000 --- a/app/api/meetup/ensure-user/route.ts +++ /dev/null @@ -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 : 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 }); - } -} diff --git a/app/api/meetup/need-password-change/route.ts b/app/api/meetup/need-password-change/route.ts deleted file mode 100644 index ebdb73b..0000000 --- a/app/api/meetup/need-password-change/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/meetup/set-needs-password-change/route.ts b/app/api/meetup/set-needs-password-change/route.ts deleted file mode 100644 index d0ec74b..0000000 --- a/app/api/meetup/set-needs-password-change/route.ts +++ /dev/null @@ -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; -} diff --git a/app/api/pay/route.ts b/app/api/pay/route.ts deleted file mode 100644 index de7209c..0000000 --- a/app/api/pay/route.ts +++ /dev/null @@ -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, ">"); -} - -function buildPayHtml( - payUrl: string, - params: Record, - options?: { directToCashier?: boolean; orderId?: string } -): string { - const inputs = Object.entries(params) - .filter(([, value]) => value != null && String(value).trim() !== "") - .map( - ([key, value]) => - `` - ) - .join("\n"); - - const pendingOrderScript = options?.orderId - ? `` - : ""; - - return options?.directToCashier - ? `支付
${inputs}
${pendingOrderScript}` - : `跳转支付

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

${inputs}
${pendingOrderScript}`; -} - -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 ` - -${esc(title)} - - -
-
-

${esc(title)}

-

${esc(hint)}

-
-
- - - - -
- 5:00 - -
-
-
- 支付二维码 -
-
-

等待支付中...

-
-
-
-
支付成功
-
${esc(successDesc)}
-
3 秒后跳转
-
-
- -`; -} - -async function createPayOrder( - apiUrl: string, - userId: string, - returnUrl: string, - totalFee: number, - type: string, - channel: string, - provider?: "xorpay", - clientIp?: string -) { - const payload: Record = { - 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, - 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 -) { - 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; - const contentType = request.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - body = (await request.json()) as Record; - } else { - const form = await request.formData(); - body = Object.fromEntries(form.entries()) as Record; - } - - 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 = { - 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 } - ); - } -} diff --git a/app/api/pay/status/route.ts b/app/api/pay/status/route.ts deleted file mode 100644 index fbe7d13..0000000 --- a/app/api/pay/status/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/upload-media/route.ts b/app/api/upload-media/route.ts deleted file mode 100644 index 468ca8f..0000000 --- a/app/api/upload-media/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts deleted file mode 100644 index 75046bb..0000000 --- a/app/api/vip/check/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/components/AuthModal.tsx b/app/components/AuthModal.tsx index 3b36886..eb6bd3f 100644 --- a/app/components/AuthModal.tsx +++ b/app/components/AuthModal.tsx @@ -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(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>, 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

- {mode === "login" ? t("login") : t("register")} + {t("login")}

- {mode === "login" ? t("loginHint") : t("registerHint")} + {t("loginHint")}

@@ -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" /> -
- - 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" - /> -
- {mode === "register" && ( -
- - 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" - /> -
- )} {error && (

{error}

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

- {mode === "login" ? ( - <> - {t("noAccount")}{" "} - - - ) : ( - <> - {t("hasAccount")}{" "} - - - )} -

+ {GOOGLE_CLIENT_ID && ( + <> +
+
+ or +
+
+ + + )}
diff --git a/app/components/GoogleLoginButton.tsx b/app/components/GoogleLoginButton.tsx new file mode 100644 index 0000000..aed516b --- /dev/null +++ b/app/components/GoogleLoginButton.tsx @@ -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 { + 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(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 ( +
+
+ {!ready &&
} +
+ ); +} diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx index 9d168da..68c81c2 100644 --- a/app/components/HeroSection.tsx +++ b/app/components/HeroSection.tsx @@ -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"), { diff --git a/app/components/JoinModal.tsx b/app/components/JoinModal.tsx index 51dea58..165e1ff 100644 --- a/app/components/JoinModal.tsx +++ b/app/components/JoinModal.tsx @@ -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(null); useEffect(() => { if (isOpen) { setEmail(initialEmail); - setPassword(""); setError(null); } }, [isOpen, initialEmail]); + const saveAuth = useCallback(async (token: string, record: Record) => { + 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

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

@@ -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" />
-
- - { 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" - /> -
{error && (

{error}

)} @@ -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 →")} + {GOOGLE_CLIENT_ID && ( + <> +
+
+ or +
+
+ + + )}
diff --git a/app/data/mock.ts b/app/data/mock.ts index 489ffcf..a11b7d2 100644 --- a/app/data/mock.ts +++ b/app/data/mock.ts @@ -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" }, diff --git a/app/lib/api-client.ts b/app/lib/api-client.ts index 82a8feb..3e6a8db 100644 --- a/app/lib/api-client.ts +++ b/app/lib/api-client.ts @@ -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( diff --git a/app/lib/minio/client.ts b/app/lib/minio/client.ts deleted file mode 100644 index 26d92ff..0000000 --- a/app/lib/minio/client.ts +++ /dev/null @@ -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 { - 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}`; -} diff --git a/app/lib/minio/config.ts b/app/lib/minio/config.ts deleted file mode 100644 index 95650e4..0000000 --- a/app/lib/minio/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getMinioConfig } from "@/config/services.config"; -export type { MinioConfig } from "@/config/services.config"; diff --git a/app/lib/minio/index.ts b/app/lib/minio/index.ts deleted file mode 100644 index 593fb28..0000000 --- a/app/lib/minio/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { getMinioConfig } from "./config"; -export type { MinioConfig } from "./config"; -export { - getMinioClient, - uploadBuffer, - generateObjectKey, -} from "./client"; -export type { UploadResult } from "./client"; diff --git a/app/lib/payment/xorpayStatus.ts b/app/lib/payment/xorpayStatus.ts deleted file mode 100644 index 618fdc9..0000000 --- a/app/lib/payment/xorpayStatus.ts +++ /dev/null @@ -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), - }; - } -} diff --git a/app/lib/payment/zpayStatus.ts b/app/lib/payment/zpayStatus.ts deleted file mode 100644 index 4bf9a0c..0000000 --- a/app/lib/payment/zpayStatus.ts +++ /dev/null @@ -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), - }; - } -} diff --git a/app/lib/pocketbase/admin.ts b/app/lib/pocketbase/admin.ts deleted file mode 100644 index 5fa5173..0000000 --- a/app/lib/pocketbase/admin.ts +++ /dev/null @@ -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 { - 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 0dbb187..029d767 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -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 { - const res = await fetch(apiUrl("/api/auth/login"), { +async function authRequest(path: string, body: Record): Promise { + 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 { - 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 { + return authRequest("/api/auth/email", { email }); +} + +export async function googleLogin(idToken: string): Promise { + return authRequest("/api/auth/google", { id_token: idToken }); } export function pbSaveAuth(token: string, record: AuthUser): void { diff --git a/app/lib/pocketbase/config.ts b/app/lib/pocketbase/config.ts deleted file mode 100644 index ae8ed27..0000000 --- a/app/lib/pocketbase/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getPocketBaseConfig } from "@/config/services.config"; -export type { PocketBaseConfig } from "@/config/services.config"; diff --git a/app/lib/pocketbase/index.ts b/app/lib/pocketbase/index.ts index aea22de..83c16be 100644 --- a/app/lib/pocketbase/index.ts +++ b/app/lib/pocketbase/index.ts @@ -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"; diff --git a/backend/init_db.py b/backend/init_db.py index e37a13b..d3b39e0 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -134,7 +134,6 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = { json_field("tags"), text("bio", max=1000), text("photo", max=500), - boolean("needsPasswordChange"), ], "member_applications": [ text("userId", max=80), @@ -158,6 +157,12 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = { text("status", max=40), date("expiresAt"), ], + "site_vip": [ + text("user_id", required=True, max=80), + text("site_id", required=True, max=80), + text("order_id", max=120), + date("expires_at"), + ], "orders": [ text("orderId", required=True, max=80), text("userId", max=120), @@ -169,6 +174,14 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = { text("returnUrl", max=500), boolean("isNew"), ], + "payments": [ + text("user_id", max=120), + text("type", max=60), + text("order_id", required=True, max=120), + number("amount", only_int=True), + number("total_fee", only_int=True), + text("channel", max=40), + ], "media_assets": [ text("userId", max=80), text("url", required=True, max=500), diff --git a/backend/main.py b/backend/main.py index 4e350f5..3da2456 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,25 +1,42 @@ from __future__ import annotations +import asyncio import secrets import uuid from datetime import datetime, timedelta, timezone +from html import escape from pathlib import Path from typing import Any -from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile +import httpx +from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.responses import Response as FastAPIResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field +from .payment import XorPayProvider, ZPayProvider, provider_for from .pb_client import PocketBaseError, pb, pb_quote from .settings import get_settings settings = get_settings() settings.upload_dir.mkdir(parents=True, exist_ok=True) +processed_orders: set[str] = set() app = FastAPI(title="NomadCNA Local API", version="0.1.0") + + +@app.middleware("http") +async def normalize_double_api_prefix(request: Request, call_next): + if request.scope.get("path", "").startswith("/api/api/"): + request.scope["path"] = request.scope["path"][4:] + raw_path = request.scope.get("raw_path") + if isinstance(raw_path, bytes) and raw_path.startswith(b"/api/api/"): + request.scope["raw_path"] = raw_path[4:] + return await call_next(request) + + app.add_middleware( CORSMiddleware, allow_origins=settings.frontend_origins, @@ -32,8 +49,10 @@ app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads" class AuthPayload(BaseModel): email: str - password: str - passwordConfirm: str | None = None + + +class GoogleAuthPayload(BaseModel): + id_token: str class SyncSessionPayload(BaseModel): @@ -43,7 +62,6 @@ class SyncSessionPayload(BaseModel): class EnsureUserPayload(BaseModel): email: str - password: str | None = None class CheckUserPayload(BaseModel): @@ -67,11 +85,6 @@ class JoinPayload(BaseModel): media: str | None = None -class ChangePasswordPayload(BaseModel): - newPassword: str - passwordConfirm: str - - class FeedbackPayload(BaseModel): type: str email: str = "" @@ -178,65 +191,129 @@ async def find_user_by_email(email: str) -> dict[str, Any] | None: async def find_membership(user_id: str) -> dict[str, Any] | None: - return await pb.first_record( + membership = await pb.first_record( "memberships", filter=f'userId={pb_quote(user_id)} && status="active"', ) + if membership: + return membership + try: + site_vip = await pb.first_record( + "site_vip", + filter=f'user_id={pb_quote(user_id)} && site_id={pb_quote(settings.site_id)}', + ) + if site_vip and not site_vip.get("expires_at"): + return site_vip + if site_vip and str(site_vip.get("expires_at")) > utc_now(): + return site_vip + except Exception: + return None + return None -async def ensure_user(email: str, password: str | None = None) -> tuple[dict[str, Any], str, bool]: +def internal_auth_password() -> str: + password = settings.email_auth_password.strip() + return password if len(password) >= 8 else "NomadCNAEmailLogin2026!" + + +async def ensure_profile_for_user( + user: dict[str, Any], + *, + email: str, + name: str = "", + photo: str = "", +) -> None: + profile_payload = { + "userId": user["id"], + "email": email, + "name": name or user.get("name") or email.split("@")[0], + "age": 0, + "location": "", + "tags": ["新成员"], + "bio": "", + "photo": photo or f"https://api.dicebear.com/7.x/initials/svg?seed={email}", + } + profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') + if profile: + await pb.update_record("profiles", profile["id"], profile_payload) + else: + await pb.create_record("profiles", profile_payload) + + +async def login_with_internal_password(email: str) -> dict[str, Any]: + return await pb.request( + "POST", + "/api/collections/users/auth-with-password", + json={"identity": email, "password": internal_auth_password()}, + ) + + +async def ensure_user( + email: str, + *, + name: str = "", + photo: str = "", +) -> tuple[dict[str, Any], str, bool]: normalized = email.lower().strip() if "@" not in normalized or "." not in normalized.split("@")[-1]: raise HTTPException(status_code=400, detail="请输入有效邮箱") existing = await find_user_by_email(normalized) - is_new = False if existing: - login_password = password or "12345678" + update_payload: dict[str, Any] = { + "password": internal_auth_password(), + "passwordConfirm": internal_auth_password(), + } + if name and not existing.get("name"): + update_payload["name"] = name + await pb.update_record("users", existing["id"], update_payload) try: - auth = await pb.request( - "POST", - "/api/collections/users/auth-with-password", - json={"identity": normalized, "password": login_password}, - ) + auth = await login_with_internal_password(normalized) except Exception: - if not password: - raise HTTPException(status_code=401, detail="请输入该邮箱的登录密码") - raise HTTPException(status_code=401, detail="邮箱或密码不正确") - return auth["record"], auth["token"], is_new + raise HTTPException(status_code=401, detail="邮箱登录失败") + await ensure_profile_for_user(auth["record"], email=normalized, name=name, photo=photo) + return auth["record"], auth["token"], False - user_password = password or "12345678" created = await pb.create_record( "users", { "email": normalized, "emailVisibility": True, "verified": True, - "password": user_password, - "passwordConfirm": user_password, - "name": normalized.split("@")[0], + "password": internal_auth_password(), + "passwordConfirm": internal_auth_password(), + "name": name or normalized.split("@")[0], }, ) - is_new = True - await pb.create_record( - "profiles", - { - "userId": created["id"], - "email": normalized, - "name": normalized.split("@")[0], - "age": 0, - "location": "", - "tags": ["新成员"], - "bio": "", - "photo": f"https://api.dicebear.com/7.x/initials/svg?seed={normalized}", - "needsPasswordChange": password is None, - }, - ) - auth = await pb.request( - "POST", - "/api/collections/users/auth-with-password", - json={"identity": normalized, "password": user_password}, - ) - return auth["record"], auth["token"], is_new + await ensure_profile_for_user(created, email=normalized, name=name, photo=photo) + auth = await login_with_internal_password(normalized) + return auth["record"], auth["token"], True + + +async def verify_google_id_token(id_token: str) -> dict[str, str]: + if not settings.google_client_id: + raise HTTPException(status_code=400, detail="未配置 Google 登录") + token = id_token.strip() + if not token: + raise HTTPException(status_code=400, detail="缺少 Google token") + try: + async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: + res = await client.get("https://oauth2.googleapis.com/tokeninfo", params={"id_token": token}) + data = res.json() if res.is_success else {} + except Exception: + raise HTTPException(status_code=401, detail="Google 登录验证失败") + if data.get("aud") != settings.google_client_id: + raise HTTPException(status_code=401, detail="Google 登录来源不匹配") + verified = data.get("email_verified") + if verified not in {True, "true", "1", 1}: + raise HTTPException(status_code=401, detail="Google 邮箱未验证") + email = str(data.get("email") or "").strip().lower() + if not email: + raise HTTPException(status_code=401, detail="Google 账号缺少邮箱") + return { + "email": email, + "name": str(data.get("name") or email.split("@")[0]), + "photo": str(data.get("picture") or ""), + } def public_record(record: dict[str, Any]) -> dict[str, Any]: @@ -247,6 +324,239 @@ def utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z") +def parse_order_id(order_id: str) -> tuple[str, str]: + if "_order_" not in order_id: + return "", "unknown" + prefix = order_id.split("_order_", 1)[0] + if "_" not in prefix: + return prefix, "unknown" + user_id, pay_type = prefix.rsplit("_", 1) + return user_id, pay_type.lower() + + +def decode_pending_email(user_id: str) -> str | None: + if not user_id.startswith("pending_"): + return None + try: + return bytes.fromhex(user_id.removeprefix("pending_")).decode("utf-8") + except Exception: + return None + + +def client_ip_from_request(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") or "" + if forwarded: + return forwarded.split(",", 1)[0].strip() + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() + return request.client.host if request.client else "" + + +def append_order_id(return_url: str, order_id: str) -> str: + if not return_url: + return f"/zh/join/paid?order_id={order_id}" + if "order_id=" in return_url or "out_trade_no=" in return_url: + return return_url + sep = "&" if "?" in return_url else "?" + return f"{return_url}{sep}order_id={order_id}" + + +def payment_name(pay_type: str) -> str: + return { + "book": "购买书籍", + "meetup": "数字游民社区报名", + "salon": "沙龙报名茶歇费", + "video": "视频解锁", + "vip": "VIP会员充值", + }.get(pay_type, "商品支付") + + +def set_pay_order_cookie(response: Response, order_id: str) -> None: + response.set_cookie("join_pay_order", f"{order_id}|{int(datetime.now().timestamp() * 1000)}", path="/", max_age=900) + + +def build_auto_submit_html(pay_url: str, params: dict[str, Any], order_id: str) -> str: + inputs = "\n".join( + f'' + for key, value in params.items() + if value is not None and str(value).strip() != "" + ) + order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True) + return f"""跳转支付 + +

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

+
{inputs}
+""" + + +def build_redirect_html(pay_url: str, order_id: str) -> str: + order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True) + return f"""跳转支付 + +

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

+""" + + +def build_qr_html(img_src: str, return_url: str, order_id: str) -> str: + order_value = escape(f"{order_id}|{int(datetime.now().timestamp() * 1000)}", quote=True) + return f"""微信扫码支付 + +

微信扫码支付

请使用微信扫描二维码完成支付

支付二维码
+""" + + +async def find_order(order_id: str) -> dict[str, Any] | None: + return await pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}') + + +async def upsert_order_record( + *, + order_id: str, + user_id: str, + email: str = "", + total_fee: int = 0, + pay_type: str = "meetup", + channel: str = "wxpay", + status: str = "pending", + return_url: str = "", +) -> dict[str, Any] | None: + payload = { + "orderId": order_id, + "userId": user_id, + "email": email, + "totalFee": total_fee, + "type": pay_type, + "channel": channel, + "status": status, + "returnUrl": return_url, + "isNew": user_id.startswith("pending_"), + } + existing = await find_order(order_id) + if existing: + return await pb.update_record("orders", existing["id"], payload) + return await pb.create_record("orders", payload) + + +async def mark_order_paid(order_id: str, pay_price: str = "") -> dict[str, Any] | None: + order = await find_order(order_id) + if order: + return await pb.update_record("orders", order["id"], {"status": "paid"}) + user_id, pay_type = parse_order_id(order_id) + total_fee = int(float(pay_price or 0) * 100) if pay_price else 0 + email = decode_pending_email(user_id) or "" + return await upsert_order_record( + order_id=order_id, + user_id=user_id, + email=email, + total_fee=total_fee, + pay_type=pay_type, + status="paid", + ) + + +async def query_gateway_paid(order_id: str) -> tuple[bool, str]: + zpay, xorpay = await asyncio.gather( + ZPayProvider().query_order_status(order_id), + XorPayProvider().query_order_status(order_id), + ) + for result in (zpay, xorpay): + if result.get("paid"): + return True, str(result.get("pay_price") or "") + return False, "" + + +async def ensure_site_vip(user_id: str, order_id: str) -> None: + try: + payload = {"user_id": user_id, "site_id": settings.site_id, "order_id": order_id, "expires_at": ""} + existing = await pb.first_record("site_vip", filter=f'user_id={pb_quote(user_id)} && site_id={pb_quote(settings.site_id)}') + if existing: + await pb.update_record("site_vip", existing["id"], payload) + else: + await pb.create_record("site_vip", payload) + except Exception: + return + + +async def ensure_paid_membership(user: dict[str, Any], order_id: str) -> None: + expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z") + payload_membership = { + "userId": user["id"], + "email": user.get("email", ""), + "plan": "pro", + "status": "active", + "expiresAt": expires, + } + membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}') + if membership: + await pb.update_record("memberships", membership["id"], payload_membership) + else: + await pb.create_record("memberships", payload_membership) + await ensure_site_vip(user["id"], order_id) + + +async def record_payment(user: dict[str, Any], order: dict[str, Any], order_id: str, pay_price: str = "") -> None: + try: + amount = int(float(pay_price or 0) * 100) if pay_price else int(order.get("totalFee") or 0) + existing = await pb.first_record("payments", filter=f'order_id={pb_quote(order_id)}') + payload = { + "user_id": user["id"], + "type": order.get("type") or parse_order_id(order_id)[1], + "order_id": order_id, + "amount": amount, + "total_fee": amount, + "channel": order.get("channel") or "wxpay", + } + if existing: + await pb.update_record("payments", existing["id"], payload) + else: + await pb.create_record("payments", payload) + except Exception: + return + + +async def user_for_paid_order(order: dict[str, Any], order_id: str) -> tuple[dict[str, Any] | None, str | None, bool]: + user_id = order.get("userId") or parse_order_id(order_id)[0] + if user_id.startswith("pending_"): + email = order.get("email") or decode_pending_email(user_id) + if not email: + return None, None, False + user, token, is_new = await ensure_user(email) + return user, token, is_new + if not user_id: + return None, None, False + user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}') + return user, None, False + + +async def finalize_paid_order(order_id: str, pay_price: str = "") -> tuple[dict[str, Any] | None, str | None, bool]: + if not order_id: + return None, None, False + order = await mark_order_paid(order_id, pay_price) + if not order: + return None, None, False + user, token, is_new = await user_for_paid_order(order, order_id) + if user: + await record_payment(user, order, order_id, pay_price) + await ensure_paid_membership(user, order_id) + processed_orders.add(order_id) + return user, token, is_new + + def default_notification_preferences(user_id: str, email: str = "") -> dict[str, Any]: return { "userId": user_id, @@ -389,32 +699,19 @@ async def health() -> dict[str, Any]: return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url} -@app.post("/api/auth/register") -async def register(payload: AuthPayload, response: Response) -> dict[str, Any]: - if len(payload.password) < 8: - raise HTTPException(status_code=400, detail="密码至少 8 位") - if payload.passwordConfirm and payload.password != payload.passwordConfirm: - raise HTTPException(status_code=400, detail="两次密码不一致") - try: - user, token, _ = await ensure_user(payload.email, payload.password) - except PocketBaseError as exc: - raise HTTPException(status_code=400, detail=str(exc)) +@app.post("/api/auth/email") +async def email_login(payload: AuthPayload, response: Response) -> dict[str, Any]: + user, token, is_new = await ensure_user(payload.email) set_session_cookie(response, token) - return {"ok": True, "token": token, "record": user, "user": user} + return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new} -@app.post("/api/auth/login") -async def login(payload: AuthPayload, response: Response) -> dict[str, Any]: - try: - auth = await pb.request( - "POST", - "/api/collections/users/auth-with-password", - json={"identity": payload.email.lower(), "password": payload.password}, - ) - except Exception: - raise HTTPException(status_code=401, detail="邮箱或密码不正确") - set_session_cookie(response, auth["token"]) - return {"ok": True, "token": auth["token"], "record": auth["record"], "user": auth["record"]} +@app.post("/api/auth/google") +async def google_login(payload: GoogleAuthPayload, response: Response) -> dict[str, Any]: + profile = await verify_google_id_token(payload.id_token) + user, token, is_new = await ensure_user(profile["email"], name=profile["name"], photo=profile["photo"]) + set_session_cookie(response, token) + return {"ok": True, "token": token, "record": user, "user": user, "is_new": is_new, "provider": "google"} @app.post("/api/auth/sync-session") @@ -439,26 +736,6 @@ async def logout(response: Response) -> dict[str, Any]: return {"ok": True} -@app.post("/api/auth/change-password") -async def change_password(payload: ChangePasswordPayload, request: Request) -> dict[str, Any]: - token, user = await current_user(request) - if not user: - raise HTTPException(status_code=401, detail="未登录") - if len(payload.newPassword) < 8: - raise HTTPException(status_code=400, detail="密码至少 8 位") - if payload.newPassword != payload.passwordConfirm: - raise HTTPException(status_code=400, detail="两次密码不一致") - await pb.update_record( - "users", - user["id"], - {"password": payload.newPassword, "passwordConfirm": payload.passwordConfirm}, - ) - profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') - if profile: - await pb.update_record("profiles", profile["id"], {"needsPasswordChange": False}) - return {"ok": True} - - @app.post("/api/meetup/check-user") async def check_user(payload: CheckUserPayload) -> dict[str, Any]: user = await find_user_by_email(str(payload.email)) @@ -468,69 +745,36 @@ async def check_user(payload: CheckUserPayload) -> dict[str, Any]: @app.post("/api/meetup/ensure-user") async def ensure_user_endpoint(payload: EnsureUserPayload, response: Response) -> dict[str, Any]: - user, token, is_new = await ensure_user(str(payload.email), payload.password) + user, token, is_new = await ensure_user(str(payload.email)) set_session_cookie(response, token) vip = bool(await find_membership(user["id"])) return {"ok": True, "user_id": user["id"], "record": user, "token": token, "is_new": is_new, "vip": vip} -@app.post("/api/meetup/set-needs-password-change") -async def set_needs_password_change(request: Request) -> dict[str, Any]: - _, user = await current_user(request) - if not user: - raise HTTPException(status_code=401, detail="未登录") - profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') - if profile: - await pb.update_record("profiles", profile["id"], {"needsPasswordChange": True}) - return {"ok": True} - - -@app.get("/api/meetup/need-password-change") -async def need_password_change(request: Request) -> dict[str, Any]: - _, user = await current_user(request) - if not user: - return {"ok": True, "need": False} - profile = await pb.first_record("profiles", filter=f'userId={pb_quote(user["id"])}') - return {"ok": True, "need": bool(profile and profile.get("needsPasswordChange"))} - - @app.post("/api/meetup/complete-order") async def complete_order(payload: CompleteOrderPayload, response: Response) -> dict[str, Any]: - order = await pb.first_record("orders", filter=f'orderId={pb_quote(payload.order_id)}') + order_id = payload.order_id.strip() + if not order_id: + raise HTTPException(status_code=400, detail="缺少 order_id") + order = await find_order(order_id) if not order: - raise HTTPException(status_code=404, detail="订单不存在") - if order.get("status") != "paid": - await pb.update_record("orders", order["id"], {"status": "paid"}) - user_id = order.get("userId", "") - user = None - if user_id and not user_id.startswith("pending_"): - user = await pb.first_record("users", filter=f'id={pb_quote(user_id)}') - if not user: - email = order.get("email") or f"{payload.order_id}@nomadcna.local" - user, token, is_new = await ensure_user(email) - else: - auth = await pb.request( - "POST", - "/api/collections/users/auth-with-password", - json={"identity": user["email"], "password": "12345678"}, - ) - token = auth["token"] - is_new = False + paid, pay_price = await query_gateway_paid(order_id) + if not paid and not settings.dev_auto_pay: + raise HTTPException(status_code=400, detail="订单不存在或未支付成功") + order = await mark_order_paid(order_id, pay_price) + elif order.get("status") != "paid": + paid, pay_price = await query_gateway_paid(order_id) + if not paid and not settings.dev_auto_pay: + raise HTTPException(status_code=400, detail="订单未支付成功") + order = await mark_order_paid(order_id, pay_price) + if not order: + raise HTTPException(status_code=500, detail="订单处理失败") - expires = (datetime.now(timezone.utc) + timedelta(days=365)).strftime("%Y-%m-%d %H:%M:%S.000Z") - membership = await pb.first_record("memberships", filter=f'userId={pb_quote(user["id"])}') - payload_membership = { - "userId": user["id"], - "email": user.get("email", ""), - "plan": "pro", - "status": "active", - "expiresAt": expires, - } - if membership: - await pb.update_record("memberships", membership["id"], payload_membership) - else: - await pb.create_record("memberships", payload_membership) - set_session_cookie(response, token) + user, token, is_new = await finalize_paid_order(order_id) + if not user: + raise HTTPException(status_code=500, detail="会员开通失败") + if token: + set_session_cookie(response, token) return {"ok": True, "token": token, "record": user, "is_new": is_new} @@ -608,7 +852,7 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s target = settings.upload_dir / name content = await file.read() target.write_bytes(content) - url = f"http://127.0.0.1:8000/uploads/{name}" + url = f"{settings.upload_public_base_url}/uploads/{name}" user_id = "" _, user = await current_user(request) user_id = user["id"] if user else "" @@ -626,63 +870,161 @@ async def upload_media(request: Request, file: UploadFile = File(...)) -> dict[s @app.post("/api/pay", response_model=None) -async def create_pay( - response: Response, - user_id: str = Form(...), - return_url: str = Form(...), - total_fee: int = Form(100), - type: str = Form("meetup"), - channel: str = Form("wxpay"), - device: str = Form("pc"), - html: str = Form("1"), -) -> HTMLResponse | dict[str, Any]: - order_id = f"local_{uuid.uuid4().hex[:16]}" - email = "" - if user_id.startswith("pending_"): - try: - raw_hex = user_id.removeprefix("pending_") - email = bytes.fromhex(raw_hex).decode("utf-8") - except Exception: - email = "" - await pb.create_record( - "orders", - { - "orderId": order_id, - "userId": user_id, - "email": email, - "totalFee": total_fee, - "type": type, - "channel": channel, - "status": "paid" if settings.dev_auto_pay else "pending", - "returnUrl": return_url, - "isNew": user_id.startswith("pending_"), - }, +async def create_pay(request: Request, response: Response) -> HTMLResponse | dict[str, Any]: + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + body = await request.json() + else: + form = await request.form() + body = dict(form) + + user_id = str(body.get("user_id") or "").strip() + if not user_id: + raise HTTPException(status_code=400, detail="缺少 user_id") + pay_type = str(body.get("type") or settings.payment_join_type or "meetup").lower() + try: + total_fee = int(float(body.get("total_fee") or 0)) + except (TypeError, ValueError): + total_fee = 0 + if total_fee <= 0: + total_fee = settings.payment_join_amount if pay_type == settings.payment_join_type else settings.payment_default_amount + channel = str(body.get("channel") or "wxpay") + device = str(body.get("device") or "pc") + requested_provider = str(body.get("provider") or "") + want_html = str(body.get("html") or "0") == "1" or "text/html" in (request.headers.get("accept") or "") + order_id = f"{user_id}_{pay_type}_order_{int(datetime.now().timestamp())}_{secrets.token_hex(4)}" + return_url = append_order_id(str(body.get("return_url") or "/zh/join/paid"), order_id) + email = decode_pending_email(user_id) or "" + await upsert_order_record( + order_id=order_id, + user_id=user_id, + email=email, + total_fee=total_fee, + pay_type=pay_type, + channel=channel, + status="paid" if settings.dev_auto_pay else "pending", + return_url=return_url, ) - cookie_value = f"{order_id}|{int(datetime.now().timestamp() * 1000)}" - response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900) - if html == "1": - redirect_url = f"{return_url}{'&' if '?' in return_url else '?'}order_id={order_id}" - html_response = HTMLResponse( - f"""本地支付 - -

NomadCNA 本地开发支付

-

订单 {order_id} 已自动标记为支付成功。

-

3 秒后返回站点完成会员开通。

- -立即返回""" - ) - html_response.set_cookie("join_pay_order", cookie_value, path="/", max_age=900) + set_pay_order_cookie(response, order_id) + + if settings.dev_auto_pay: + redirect_url = return_url + html_response = HTMLResponse(build_redirect_html(redirect_url, order_id)) + set_pay_order_cookie(html_response, order_id) + return html_response if want_html else {"ok": True, "order_id": order_id, "paid": True} + + amount_yuan = f"{total_fee / 100:.2f}" + provider = provider_for(device, requested_provider, request.headers.get("user-agent", "")) + notify_url = f"{settings.base_url}/api/pay/{provider.name}_notify" + name = payment_name(pay_type) + client_ip = client_ip_from_request(request) + + if isinstance(provider, XorPayProvider): + created = provider.create_order(order_id=order_id, name=name, amount_yuan=amount_yuan, notify_url=notify_url) + if not want_html: + return {"ok": True, "order_id": order_id, "provider": provider.name, "params": created["params"], "payUrl": created["pay_url"]} + html_response = HTMLResponse(build_auto_submit_html(created["pay_url"], created["params"], order_id)) + set_pay_order_cookie(html_response, order_id) return html_response - return {"ok": True, "order_id": order_id, "paid": settings.dev_auto_pay} + + api_result = await provider.create_order_api( + order_id=order_id, + name=name, + amount_yuan=amount_yuan, + channel=channel, + notify_url=notify_url, + return_url=return_url, + client_ip=client_ip, + device=device, + ) + if api_result.get("status") == "ok": + if not want_html: + return {"ok": True, "order_id": order_id, "provider": provider.name, **api_result} + if device == "pc" and api_result.get("img"): + html_response = HTMLResponse(build_qr_html(str(api_result["img"]), return_url, order_id)) + set_pay_order_cookie(html_response, order_id) + return html_response + pay_url = api_result.get("payurl2") if device == "h5" and api_result.get("payurl2") else api_result.get("payurl") + if pay_url: + html_response = HTMLResponse(build_redirect_html(str(pay_url), order_id)) + set_pay_order_cookie(html_response, order_id) + return html_response + + created = provider.create_order( + order_id=order_id, + name=name, + amount_yuan=amount_yuan, + channel=channel, + notify_url=notify_url, + return_url=return_url, + client_ip=client_ip, + ) + if not want_html: + return {"ok": True, "order_id": order_id, "provider": provider.name, "params": created["params"], "payUrl": created["pay_url"]} + html_response = HTMLResponse(build_auto_submit_html(created["pay_url"], created["params"], order_id)) + set_pay_order_cookie(html_response, order_id) + return html_response @app.get("/api/pay/status") async def pay_status(order_id: str) -> dict[str, Any]: - order = await pb.first_record("orders", filter=f'orderId={pb_quote(order_id)}') - return {"ok": True, "paid": bool(order and order.get("status") == "paid"), "order": order} + order = await find_order(order_id) + if order and order.get("status") == "paid": + return {"ok": True, "paid": True, "order": order} + paid, pay_price = await query_gateway_paid(order_id) + if paid: + order = await mark_order_paid(order_id, pay_price) + return {"ok": True, "paid": paid, "order": order} + + +@app.get("/cnomadcna/order_status") +async def cnomadcna_order_status(order_id: str = "", out_trade_no: str = "") -> dict[str, Any]: + target = (order_id or out_trade_no).strip() + if not target: + return {"paid": False, "error": "missing order_id"} + result = await pay_status(target) + return {"paid": bool(result.get("paid")), "order": result.get("order")} + + +@app.get("/cnomadcna/zpay_order_status") +async def cnomadcna_zpay_order_status(out_trade_no: str) -> dict[str, Any]: + return await ZPayProvider().query_order_status(out_trade_no) + + +@app.api_route("/api/pay/zpay_notify", methods=["GET", "POST"]) +@app.api_route("/cnomadcna/zpay_notify", methods=["GET", "POST"]) +async def zpay_notify(request: Request) -> FastAPIResponse: + if request.method == "GET": + data = dict(request.query_params) + else: + form = await request.form() + data = dict(form) + provider = ZPayProvider() + order_id = str(data.get("out_trade_no", "")) + if order_id in processed_orders: + return FastAPIResponse(provider.success_response(), media_type="text/plain") + if not provider.verify_notify(data): + raise HTTPException(status_code=400, detail="sign error") + parsed = provider.parse_notify(data) + if parsed.get("trade_status") == "success": + await finalize_paid_order(parsed.get("order_id", ""), parsed.get("pay_price", "")) + return FastAPIResponse(provider.success_response(), media_type="text/plain") + + +@app.post("/api/pay/xorpay_notify") +@app.post("/cnomadcna/xorpay_notify") +async def xorpay_notify(request: Request) -> FastAPIResponse: + form = await request.form() + data = dict(form) + provider = XorPayProvider() + order_id = str(data.get("order_id", "")) + if order_id in processed_orders: + return FastAPIResponse(provider.success_response(), media_type="text/plain") + if not provider.verify_notify(data): + raise HTTPException(status_code=400, detail="sign error") + parsed = provider.parse_notify(data) + await finalize_paid_order(parsed.get("order_id", ""), parsed.get("pay_price", "")) + return FastAPIResponse(provider.success_response(), media_type="text/plain") @app.get("/api/cities") diff --git a/backend/payment.py b/backend/payment.py new file mode 100644 index 0000000..b0ffb08 --- /dev/null +++ b/backend/payment.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import httpx + +from .settings import get_settings + + +def _md5(value: str) -> str: + return hashlib.md5(value.encode("utf-8")).hexdigest().lower() + + +def _is_internal_ip(ip: str | None) -> bool: + if not ip: + return True + value = ip.strip().lower() + if value in {"127.0.0.1", "localhost", "::1"}: + return True + if value.startswith("10.") or value.startswith("192.168."): + return True + if value.startswith("172."): + parts = value.split(".") + if len(parts) > 1 and parts[1].isdigit(): + return 16 <= int(parts[1]) <= 31 + return False + + +def resolve_channel(channel: str | None) -> str: + return "wxpay" if str(channel or "").lower() in {"", "wx", "wechat", "wxpay"} else "alipay" + + +class ZPayProvider: + name = "zpay" + + def __init__(self) -> None: + settings = get_settings() + self.pid = settings.zpay_pid + self.key = settings.zpay_key + self.submit_url = settings.zpay_submit_url + self.mapi_url = settings.zpay_mapi_url + self.query_url = settings.zpay_query_url + + def sign(self, params: dict[str, Any]) -> str: + filtered = { + key: value + for key, value in params.items() + if key not in {"sign", "sign_type"} and value is not None and str(value) != "" + } + raw = "&".join(f"{key}={filtered[key]}" for key in sorted(filtered)) + self.key + return _md5(raw) + + def create_order( + self, + *, + order_id: str, + name: str, + amount_yuan: str, + channel: str, + notify_url: str, + return_url: str, + client_ip: str = "", + ) -> dict[str, Any]: + params: dict[str, Any] = { + "pid": self.pid, + "type": resolve_channel(channel), + "out_trade_no": order_id, + "notify_url": notify_url, + "return_url": return_url, + "name": name, + "money": amount_yuan, + "sign_type": "MD5", + } + if client_ip and not _is_internal_ip(client_ip): + params["clientip"] = client_ip + params["sign"] = self.sign(params) + return { + "status": "ok", + "provider": self.name, + "params": params, + "pay_url": self.submit_url, + "submit_method": "POST", + } + + async def create_order_api( + self, + *, + order_id: str, + name: str, + amount_yuan: str, + channel: str, + notify_url: str, + return_url: str, + client_ip: str = "", + device: str = "pc", + ) -> dict[str, Any]: + params: dict[str, Any] = { + "pid": self.pid, + "type": resolve_channel(channel), + "out_trade_no": order_id, + "notify_url": notify_url, + "return_url": return_url, + "name": name, + "money": amount_yuan, + "device": device, + "sign_type": "MD5", + } + if client_ip and not _is_internal_ip(client_ip): + params["clientip"] = client_ip + params["sign"] = self.sign(params) + + try: + async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client: + res = await client.post(self.mapi_url, data=params) + except Exception as exc: + return {"status": "error", "provider": self.name, "msg": str(exc)} + + result: Any + try: + result = res.json() + except Exception: + result = {} + if isinstance(result, str): + try: + result = json.loads(result) + except Exception: + result = {} + if not isinstance(result, dict): + result = {} + msg = result.get("msg") + if isinstance(msg, str) and msg.strip().startswith("{"): + try: + inner = json.loads(msg) + if isinstance(inner, dict): + result = inner + except Exception: + pass + try: + code = int(float(result.get("code", 0))) + except (TypeError, ValueError): + code = 0 + if code == 1: + return { + "status": "ok", + "provider": self.name, + "payurl": result.get("payurl"), + "payurl2": result.get("payurl2"), + "qrcode": result.get("qrcode"), + "img": result.get("img"), + "trade_no": result.get("trade_no"), + } + return {"status": "error", "provider": self.name, "msg": result.get("msg") or res.text[:500]} + + def verify_notify(self, data: dict[str, Any]) -> bool: + return self.sign(data) == str(data.get("sign", "")) + + def parse_notify(self, data: dict[str, Any]) -> dict[str, Any]: + return { + "order_id": str(data.get("out_trade_no", "")), + "trade_no": str(data.get("trade_no", "")), + "pay_price": str(data.get("money", "")), + "trade_status": "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending", + } + + async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]: + try: + async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + res = await client.get( + self.query_url, + params={ + "act": "order", + "pid": self.pid, + "key": self.key, + "out_trade_no": order_id, + }, + ) + data = res.json() if res.is_success else {} + except Exception as exc: + return {"paid": False, "error": str(exc)} + try: + code = int(float(data.get("code", 0))) + status = int(float(data.get("status", 0))) + except (TypeError, ValueError): + return {"paid": False, "msg": data.get("msg", "查询失败")} + pay_price = data.get("money") or data.get("pay_price") or "" + return {"paid": code == 1 and status == 1, "status": status, "pay_price": str(pay_price)} + + def success_response(self) -> str: + return "success" + + +class XorPayProvider: + name = "xorpay" + + def __init__(self) -> None: + settings = get_settings() + self.aid = settings.xorpay_aid + self.secret = settings.xorpay_secret + self.cashier_url = settings.xorpay_cashier_url.rstrip("/") + self.query_url = settings.xorpay_query_url.rstrip("/") + + def create_order( + self, + *, + order_id: str, + name: str, + amount_yuan: str, + notify_url: str, + ) -> dict[str, Any]: + params = { + "name": name, + "pay_type": "jsapi", + "price": amount_yuan, + "order_id": order_id, + "notify_url": notify_url, + } + params["sign"] = _md5( + params["name"] + params["pay_type"] + params["price"] + params["order_id"] + params["notify_url"] + self.secret + ) + return {"status": "ok", "provider": self.name, "params": params, "pay_url": f"{self.cashier_url}/{self.aid}"} + + def verify_notify(self, data: dict[str, Any]) -> bool: + raw = ( + str(data.get("aoid", "")) + + str(data.get("order_id", "")) + + str(data.get("pay_price", "")) + + str(data.get("pay_time", "")) + + self.secret + ) + return _md5(raw) == str(data.get("sign", "")) + + def parse_notify(self, data: dict[str, Any]) -> dict[str, Any]: + return { + "order_id": str(data.get("order_id", "")), + "trade_no": str(data.get("aoid", "")), + "pay_price": str(data.get("pay_price", "")), + "trade_status": "success", + } + + async def query_order_status(self, order_id: str, timeout: float = 8.0) -> dict[str, Any]: + sign = _md5(order_id + self.secret) + try: + async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + res = await client.get(f"{self.query_url}/{self.aid}", params={"order_id": order_id, "sign": sign}) + data = res.json() if res.is_success else {} + status = str(data.get("status", "")).lower() + return { + "paid": status in {"payed", "success"}, + "status": status, + "pay_price": data.get("pay_price") or data.get("price") or "", + } + except Exception as exc: + return {"paid": False, "error": str(exc)} + + def success_response(self) -> str: + return "ok" + + +def provider_for(device: str = "pc", requested: str = "", user_agent: str = "") -> ZPayProvider | XorPayProvider: + req = requested.strip().lower() + if req == "xorpay": + return XorPayProvider() + if req == "zpay": + return ZPayProvider() + if device.lower() == "wechat" or "micromessenger" in user_agent.lower(): + return XorPayProvider() + return ZPayProvider() diff --git a/backend/settings.py b/backend/settings.py index dff6961..1a9ce55 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -5,15 +5,68 @@ from functools import lru_cache from pathlib import Path +ROOT_DIR = Path(__file__).resolve().parent.parent + + +def _read_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key: + values[key] = value + return values + + +def _load_env_files() -> None: + loaded: dict[str, str] = {} + for path in [ + ROOT_DIR / ".env", + ROOT_DIR / ".env.local", + ROOT_DIR / "backend" / ".env", + ROOT_DIR / "backend" / ".env.local", + ]: + loaded.update(_read_env_file(path)) + for key, value in loaded.items(): + os.environ.setdefault(key, value) + + +_load_env_files() + + def _split_csv(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] +def _bool_env(name: str, default: str = "false") -> bool: + return os.getenv(name, default).lower() in {"1", "true", "yes", "on"} + + class Settings: def __init__(self) -> None: - self.pb_url = os.getenv("PB_URL", "http://127.0.0.1:8090").rstrip("/") - self.pb_admin_email = os.getenv("PB_ADMIN_EMAIL", "admin@nomadcna.local") - self.pb_admin_password = os.getenv("PB_ADMIN_PASSWORD", "NomadCNA123456") + self.pb_url = ( + os.getenv("PB_INTERNAL_URL") + or os.getenv("POCKETBASE_INTERNAL_URL") + or os.getenv("PB_URL") + or os.getenv("POCKETBASE_URL") + or "http://127.0.0.1:8090" + ).rstrip("/") + self.pb_admin_email = ( + os.getenv("PB_ADMIN_EMAIL") + or os.getenv("POCKETBASE_EMAIL") + or "admin@nomadcna.local" + ) + self.pb_admin_password = ( + os.getenv("PB_ADMIN_PASSWORD") + or os.getenv("POCKETBASE_PASSWORD") + or "NomadCNA123456" + ) self.cookie_name = os.getenv("API_COOKIE_NAME", "pb_session") self.frontend_origins = _split_csv( os.getenv( @@ -22,7 +75,38 @@ class Settings: ) ) self.upload_dir = Path(os.getenv("UPLOAD_DIR", "backend/uploads")) - self.dev_auto_pay = os.getenv("DEV_AUTO_PAY", "true").lower() == "true" + self.dev_auto_pay = _bool_env("DEV_AUTO_PAY", "true") + self.site_id = os.getenv("SITE_ID") or os.getenv("NEXT_PUBLIC_SITE_ID") or "meetup" + self.base_url = ( + os.getenv("API_PUBLIC_BASE_URL") + or os.getenv("BASE_URL") + or os.getenv("PAYMENT_API_URL") + or os.getenv("NEXT_PUBLIC_API_BASE_URL") + or "http://127.0.0.1:8000" + ).rstrip("/") + if self.base_url == "/api": + self.base_url = (os.getenv("NEXT_PUBLIC_SITE_URL") or "http://127.0.0.1:3001").rstrip("/") + self.upload_public_base_url = ( + os.getenv("UPLOAD_PUBLIC_BASE_URL") + or os.getenv("API_PUBLIC_BASE_URL") + or os.getenv("BASE_URL") + or self.base_url + ).rstrip("/") + self.email_auth_password = os.getenv("EMAIL_AUTH_PASSWORD", "NomadCNAEmailLogin2026!") + self.google_client_id = os.getenv("GOOGLE_CLIENT_ID") or os.getenv("NEXT_PUBLIC_GOOGLE_CLIENT_ID") or "" + self.payment_provider = os.getenv("PAYMENT_PROVIDER", "zpay").lower() + self.payment_default_amount = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "100") or "100") + self.payment_join_amount = int(os.getenv("PAYMENT_JOIN_AMOUNT", str(self.payment_default_amount)) or str(self.payment_default_amount)) + self.payment_join_type = os.getenv("PAYMENT_JOIN_TYPE", "meetup") + self.zpay_pid = os.getenv("ZPAY_PID", "2025121809351743") + self.zpay_key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89") + self.zpay_submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php") + self.zpay_mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php") + self.zpay_query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php") + self.xorpay_aid = os.getenv("XORPAY_AID", "8220") + self.xorpay_secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e") + self.xorpay_cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier") + self.xorpay_query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2") @lru_cache(maxsize=1) diff --git a/config/domain.config.ts b/config/domain.config.ts deleted file mode 100644 index db7b53b..0000000 --- a/config/domain.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * 域名与根域配置 - 特色标识 - * 更换域名(如 *.hackrobot.cn -> *.nomadro.com)时,只需设置 ROOT_DOMAIN 或各独立变量 - */ - -const isDev = process.env.NODE_ENV === "development"; - -export const ROOT_DOMAIN = - process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn"; - -/** 支付 API 默认地址。生产环境固定为 api 域名,避免 .env 中 127.0.0.1 导致支付失败 */ -export const DEFAULT_PAYMENT_API_URL = isDev - ? "http://127.0.0.1:8007" - : `https://api.${ROOT_DOMAIN}`; - -/** 生产环境若 PAYMENT_API_URL 指向 localhost,则忽略并使用 api 域名 */ -export function resolvePaymentApiUrl(): string { - const url = process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL; - if (!isDev && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) { - return `https://api.${ROOT_DOMAIN}`; - } - return url; -} - -export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`; - -export const DEFAULT_MINIO_HOST = `minioweb.${ROOT_DOMAIN}`; - -/** digital 专题站默认地址(用于跨站链接) */ -export const DEFAULT_DIGITAL_URL = `https://digital.${ROOT_DOMAIN}`; diff --git a/config/index.ts b/config/index.ts index a8ff674..d113ea2 100644 --- a/config/index.ts +++ b/config/index.ts @@ -4,14 +4,10 @@ export { getHomeConfig, HOME_CONFIG, MEMBER_PHOTOS, TRAVELING_NOW, MEETUPS, HOT_ export { FILTER_OPTIONS, FILTER_LABELS, type FilterOption } from "./filter.config"; export { FOOTER_SECTIONS, getFooterSections, type FooterLink, type FooterSection } from "./footer.config"; export { - getPocketBaseConfig, getPaymentConfig, getJoinPaymentConfig, - getMinioConfig, SITE_ID, } from "./services.config"; export type { - PocketBaseConfig, PaymentConfig, - MinioConfig, } from "./services.config"; diff --git a/config/services.config.ts b/config/services.config.ts index 8268d16..67c4522 100644 --- a/config/services.config.ts +++ b/config/services.config.ts @@ -1,80 +1,23 @@ /** - * 服务配置 - PocketBase、支付、MinIO - * 域名更换:设置 ROOT_DOMAIN(如 nomadro.com)可推导默认 API 地址 + * 前端服务配置。 + * 浏览器侧只保留 UI 需要的公开配置;后端地址、支付网关、存储和 PB 连接都由 FastAPI 持有。 */ -import { - DEFAULT_PAYMENT_API_URL, - DEFAULT_POCKETBASE_URL, - DEFAULT_MINIO_HOST, - resolvePaymentApiUrl, -} from "./domain.config"; - -export interface PocketBaseConfig { - enabled: boolean; - url: string; - usersCollection: string; - joinCollection: string; -} - export interface PaymentConfig { - enabled: boolean; - apiUrl: string; - provider: "zpay" | "xorpay"; defaultAmount: number; defaultType: string; - zpaySubmitUrl: string; } -export interface MinioConfig { - enabled: boolean; - endPoint: string; - port: number; - useSSL: boolean; - bucket: string; - accessKey: string; - secretKey: string; - region: string; - publicUrl: string; - uploadPrefix: string; -} - -const isDev = process.env.NODE_ENV === "development"; -const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007"; - /** 站点标识,用于 VIP 按站点隔离 */ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "meetup"; -/** meetup 加入游牧中国 价格(分),88元=8800 */ +/** meetup 加入游牧中国价格(分),88 元 = 8800 */ export const MEETUP_JOIN_AMOUNT = 8800; -export function getApiUrlFromRequest(hostHeader: string | null): string | null { - if (!hostHeader) return null; - if (!isDev) return resolvePaymentApiUrl(); - const hostname = hostHeader.split(":")[0]; - return `http://${hostname}:${PAYJSAPI_PORT}`; -} - -export function getPocketBaseConfig(): PocketBaseConfig { - return { - enabled: true, - url: - process.env.NEXT_PUBLIC_POCKETBASE_URL || - process.env.POCKETBASE_URL || - DEFAULT_POCKETBASE_URL, - usersCollection: "users", - joinCollection: process.env.POCKETBASE_JOIN_COLLECTION || "solan", - }; -} - export function getPaymentConfig(): PaymentConfig { return { - enabled: true, - apiUrl: resolvePaymentApiUrl(), - provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay", defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 8800, defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup", - zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php", }; } @@ -85,29 +28,3 @@ export function getJoinPaymentConfig(): { amount: number; type: string } { type: process.env.PAYMENT_JOIN_TYPE || "meetup", }; } - -export function getMinioConfig(themeOverride?: { - bucket?: string; - uploadPrefix?: string; -}): MinioConfig { - const endPoint = process.env.MINIO_ENDPOINT || DEFAULT_MINIO_HOST; - const port = parseInt(process.env.MINIO_PORT || "9035", 10); - const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot"; - const publicUrl = - process.env.MINIO_PUBLIC_URL || `https://${endPoint}/${bucket}`; - const uploadPrefix = - themeOverride?.uploadPrefix || process.env.MINIO_UPLOAD_PREFIX || "joins"; - - return { - enabled: true, - endPoint, - port, - useSSL: process.env.MINIO_USE_SSL === "true", - bucket, - accessKey: process.env.MINIO_ACCESS_KEY || "", - secretKey: process.env.MINIO_SECRET_KEY || "", - region: process.env.MINIO_REGION || "china", - publicUrl: publicUrl.replace(/\/$/, ""), - uploadPrefix, - }; -} diff --git a/messages/en.json b/messages/en.json index 3081258..ee77ce3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,7 +5,7 @@ "siteNameShort": "NomadCNA", "currency": "CNY ¥", "logout": "Logout", - "loginRegister": "Login / Sign up", + "loginRegister": "Email / Google login", "explore": "Explore →", "toggleMenu": "Toggle menu", "themeLight": "Switch to dark mode", @@ -308,22 +308,12 @@ "submitAnother": "Submit another" }, "auth": { - "title": "Login / Sign up", + "title": "Email / Google login", "login": "Login", - "register": "Register", "email": "Email", - "password": "Password", - "passwordConfirm": "Confirm Password", "submit": "Login", "close": "Close", - "loginHint": "Login with email and password", - "registerHint": "Create new account", - "noAccount": "No account yet?", - "hasAccount": "Already have an account?", - "signUpNow": "Sign up now", - "goLogin": "Go to login", - "passwordMismatch": "Passwords do not match", - "passwordMinLength": "Password must be at least 8 characters" + "loginHint": "Enter your email or continue with Google" }, "pricing": { "title": "Membership", diff --git a/messages/zh.json b/messages/zh.json index 556acbf..6146d16 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -5,7 +5,7 @@ "siteNameShort": "游牧中国", "currency": "CNY ¥", "logout": "退出", - "loginRegister": "登录 / 注册", + "loginRegister": "邮箱 / Google 登录", "explore": "开始探索 →", "toggleMenu": "切换菜单", "themeLight": "切换到夜间模式", @@ -308,22 +308,12 @@ "submitAnother": "再提交一条" }, "auth": { - "title": "登录 / 注册", + "title": "邮箱 / Google 登录", "login": "登录", - "register": "注册", "email": "邮箱", - "password": "密码", - "passwordConfirm": "确认密码", "submit": "登录", "close": "关闭", - "loginHint": "使用邮箱密码登录", - "registerHint": "创建新账号", - "noAccount": "还没有账号?", - "hasAccount": "已有账号?", - "signUpNow": "立即注册", - "goLogin": "去登录", - "passwordMismatch": "两次密码不一致", - "passwordMinLength": "密码至少 8 位" + "loginHint": "输入邮箱登录,或使用 Google 继续" }, "pricing": { "title": "会员体系", diff --git a/next.config.ts b/next.config.ts index d247f8f..316716c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,14 +1,22 @@ import type { NextConfig } from "next"; +const fastApiTarget = ( + process.env.FASTAPI_PROXY_TARGET || + process.env.BACKEND_API_BASE_URL || + process.env.API_PROXY_TARGET || + "http://127.0.0.1:8000" +).replace(/\/$/, ""); + const nextConfig: NextConfig = { async rewrites() { - return [ - { - source: - "/api/:path((?:health|cities|content|notifications|recommendations|routes|services|downloads|meetups|discussions|gigs|profiles|matches|feedback|stats|ai).*)", - destination: "http://127.0.0.1:8000/api/:path", - }, - ]; + return { + beforeFiles: [ + { + source: "/api/:path*", + destination: `${fastApiTarget}/api/:path*`, + }, + ], + }; }, allowedDevOrigins: [ "localhost", diff --git a/package.json b/package.json index 54026d1..e44caa7 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,7 @@ }, "dependencies": { "echarts": "^6.0.0", - "minio": "^8.0.7", "next": "16.1.6", - "pocketbase": "^0.26.8", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63de4a9..12fb0b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,15 +11,9 @@ importers: echarts: specifier: ^6.0.0 version: 6.0.0 - minio: - specifier: ^8.0.7 - version: 8.0.7 next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - pocketbase: - specifier: ^0.26.8 - version: 0.26.8 react: specifier: 19.2.3 version: 19.2.3 @@ -238,6 +232,7 @@ packages: resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} @@ -748,9 +743,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -775,9 +767,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - block-stream2@2.1.0: - resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -789,18 +778,11 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browser-or-node@2.1.1: - resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-crc32@1.0.0: - resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} - engines: {node: '>=8.0.0'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -879,10 +861,6 @@ packages: supports-color: optional: true - decode-uri-component@0.2.2: - resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} - engines: {node: '>=0.10'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1079,9 +1057,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1095,13 +1070,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-xml-builder@1.0.0: - resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==} - - fast-xml-parser@5.4.2: - resolution: {integrity: sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==} - hasBin: true - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1122,10 +1090,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - filter-obj@1.1.0: - resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} - engines: {node: '>=0.10.0'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1250,17 +1214,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1503,9 +1460,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1528,14 +1482,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1546,10 +1492,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minio@8.0.7: - resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} - engines: {node: ^16 || ^18 || >=20} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1668,9 +1610,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pocketbase@0.26.8: - resolution: {integrity: sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==} - possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1694,10 +1633,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - query-string@7.1.3: - resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} - engines: {node: '>=6'} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1713,10 +1648,6 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1753,9 +1684,6 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -1764,10 +1692,6 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sax@1.5.0: - resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} - engines: {node: '>=11.0.0'} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1824,10 +1748,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - split-on-first@1.1.0: - resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} - engines: {node: '>=6'} - stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -1835,16 +1755,6 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stream-chain@2.2.5: - resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} - - stream-json@1.9.1: - resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} - - strict-uri-encode@2.0.0: - resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} - engines: {node: '>=4'} - string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -1868,9 +1778,6 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -1879,9 +1786,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.2.0: - resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -1910,9 +1814,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - through2@4.0.2: - resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1987,9 +1888,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2015,14 +1913,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - xml2js@0.6.2: - resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} - engines: {node: '>=4.0.0'} - - xmlbuilder@11.0.1: - resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} - engines: {node: '>=4.0'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2726,8 +2616,6 @@ snapshots: async-function@1.0.0: {} - async@3.2.6: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -2742,10 +2630,6 @@ snapshots: baseline-browser-mapping@2.10.0: {} - block-stream2@2.1.0: - dependencies: - readable-stream: 3.6.2 - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2759,8 +2643,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browser-or-node@2.1.1: {} - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -2769,8 +2651,6 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-crc32@1.0.0: {} - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2845,8 +2725,6 @@ snapshots: dependencies: ms: 2.1.3 - decode-uri-component@0.2.2: {} - deep-is@0.1.4: {} define-data-property@1.1.4: @@ -3197,8 +3075,6 @@ snapshots: esutils@2.0.3: {} - eventemitter3@5.0.4: {} - fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3213,13 +3089,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-xml-builder@1.0.0: {} - - fast-xml-parser@5.4.2: - dependencies: - fast-xml-builder: 1.0.0 - strnum: 2.2.0 - fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3236,8 +3105,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - filter-obj@1.1.0: {} - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3359,16 +3226,12 @@ snapshots: imurmurhash@0.1.4: {} - inherits@2.0.4: {} - internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 - ipaddr.js@2.3.0: {} - is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -3593,8 +3456,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.23: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -3616,12 +3477,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -3632,22 +3487,6 @@ snapshots: minimist@1.2.8: {} - minio@8.0.7: - dependencies: - async: 3.2.6 - block-stream2: 2.1.0 - browser-or-node: 2.1.1 - buffer-crc32: 1.0.0 - eventemitter3: 5.0.4 - fast-xml-parser: 5.4.2 - ipaddr.js: 2.3.0 - lodash: 4.17.23 - mime-types: 2.1.35 - query-string: 7.1.3 - stream-json: 1.9.1 - through2: 4.0.2 - xml2js: 0.6.2 - ms@2.1.3: {} nanoid@3.3.11: {} @@ -3770,8 +3609,6 @@ snapshots: picomatch@4.0.3: {} - pocketbase@0.26.8: {} - possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -3796,13 +3633,6 @@ snapshots: punycode@2.3.1: {} - query-string@7.1.3: - dependencies: - decode-uri-component: 0.2.2 - filter-obj: 1.1.0 - split-on-first: 1.1.0 - strict-uri-encode: 2.0.0 - queue-microtask@1.2.3: {} react-dom@19.2.3(react@19.2.3): @@ -3814,12 +3644,6 @@ snapshots: react@19.2.3: {} - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -3873,8 +3697,6 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -3886,8 +3708,6 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sax@1.5.0: {} - scheduler@0.27.0: {} semver@6.3.1: {} @@ -3984,8 +3804,6 @@ snapshots: source-map-js@1.2.1: {} - split-on-first@1.1.0: {} - stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -3993,14 +3811,6 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-chain@2.2.5: {} - - stream-json@1.9.1: - dependencies: - stream-chain: 2.2.5 - - strict-uri-encode@2.0.0: {} - string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -4051,16 +3861,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} - strnum@2.2.0: {} - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): dependencies: client-only: 0.0.1 @@ -4078,10 +3882,6 @@ snapshots: tapable@2.3.0: {} - through2@4.0.2: - dependencies: - readable-stream: 3.6.2 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -4199,8 +3999,6 @@ snapshots: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: {} - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4248,13 +4046,6 @@ snapshots: word-wrap@1.2.5: {} - xml2js@0.6.2: - dependencies: - sax: 1.5.0 - xmlbuilder: 11.0.1 - - xmlbuilder@11.0.1: {} - yallist@3.1.1: {} yocto-queue@0.1.0: {}