This commit is contained in:
eric
2026-06-07 01:17:46 -05:00
parent 283d65d1d1
commit cd885f5fcd
110 changed files with 17876 additions and 11 deletions

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/digital/services.config";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const identity = String(body?.identity || body?.email || "").trim();
const password = String(body?.password || "");
if (!identity || !password) {
return NextResponse.json({ ok: false, error: "Missing identity or password" }, { status: 400 });
}
const pb = getPocketBaseConfig();
const pbRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
});
const pbData = await pbRes.json().catch(() => ({}));
if (!pbRes.ok || !pbData?.token || !pbData?.record?.id) {
return NextResponse.json(
{ ok: false, error: pbData?.message || "Login failed" },
{ status: pbRes.status || 401 }
);
}
const payload = JSON.stringify({
token: pbData.token,
userId: pbData.record.id,
email: pbData.record.email ?? identity,
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({
ok: true,
token: pbData.token,
record: pbData.record,
user: { id: pbData.record.id, email: pbData.record.email ?? identity },
});
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

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

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/digital/services.config";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
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}` },
});
if (!res.ok) {
const r = NextResponse.json({ ok: true, user: null });
r.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return r;
}
const 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 r = NextResponse.json({
ok: true,
user: { id: newRecord.id, email: newRecord.email ?? email },
token: newToken,
});
r.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return r;
}
return NextResponse.json({
ok: true,
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
token: data.token,
});
} catch {
return NextResponse.json({ ok: true, user: null });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/digital/services.config";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const email = String(body?.email || "").trim();
const password = String(body?.password || "");
const passwordConfirm = String(body?.passwordConfirm || "");
if (!email || !password || !passwordConfirm) {
return NextResponse.json(
{ ok: false, error: "Missing email/password/passwordConfirm" },
{ status: 400 }
);
}
const pb = getPocketBaseConfig();
const createRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/records`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirm }),
});
const createData = await createRes.json().catch(() => ({}));
if (!createRes.ok) {
return NextResponse.json(
{ ok: false, error: createData?.message || "Register failed" },
{ status: createRes.status || 400 }
);
}
const loginRes = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
const loginData = await loginRes.json().catch(() => ({}));
if (!loginRes.ok || !loginData?.token || !loginData?.record?.id) {
return NextResponse.json(
{ ok: false, error: loginData?.message || "Auto login failed after register" },
{ status: loginRes.status || 401 }
);
}
const payload = JSON.stringify({
token: loginData.token,
userId: loginData.record.id,
email: loginData.record.email ?? email,
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({
ok: true,
token: loginData.token,
record: loginData.record,
user: { id: loginData.record.id, email: loginData.record.email ?? email },
});
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/digital/lib/auth-cookie";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const token = body?.token;
const record = body?.record;
if (!token || !record?.id) {
return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
}
const payload = JSON.stringify({
token,
userId: record.id,
email: record.email ?? "",
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
return res;
}

View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/digital/services.config";
import { getThemeConfig } from "@/config/digital";
const COOKIE_NAME = "pb_session";
function getOrCreateUserId(request: NextRequest): string {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (cookie) {
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
if (payload?.userId) return payload.userId;
} catch {
/* ignore */
}
}
return `user${Date.now()}`;
}
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.token ?? null;
}
export async function POST(request: NextRequest) {
try {
const userId = getOrCreateUserId(request);
const body = await request.json();
const formData = body && typeof body === "object" ? body : {};
const {
nickname,
gender,
single,
profession,
intro,
wechat,
education,
graduationYear,
phone,
media: mediaUrl,
} = formData;
// reason=自我介绍, single=感情状态, media=MinIO 上传后的 URL
const record: Record<string, string | number> = {
nickname: nickname || "",
occupation: profession || "",
reason: intro || "",
single: single || "",
wechatId: wechat || "",
gender: gender || "",
education: education || "",
gradYear: graduationYear || "",
phone: phone || "",
user_id: userId,
amount: 0,
order_id: "",
type: "",
};
if (mediaUrl && typeof mediaUrl === "string") {
(record as Record<string, string>)["media"] = mediaUrl;
}
const theme = getThemeConfig();
const pb = getPocketBaseConfig({
joinCollection: theme.services?.pocketbaseJoinCollection,
});
const collection = pb.joinCollection;
const doCreate = async (authToken?: string) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authToken) headers["Authorization"] = authToken;
return fetch(`${pb.url}/api/collections/${collection}/records`, {
method: "POST",
headers,
body: JSON.stringify(record),
});
};
let res = await doCreate();
if (res.status === 403) {
const token = await getAdminToken();
if (token) res = await doCreate(token);
}
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 }
);
}
}

View File

@@ -0,0 +1,169 @@
/**
* 支付确认:对齐 nomadvip 标准。payjsapi 异步通知未到达时,由前端直接写入 PocketBase site_vip
*/
import { NextRequest, NextResponse } from "next/server";
import {
getApiUrlFromRequest,
getPocketBaseConfig,
getPaymentConfig,
getJoinPaymentConfig,
SITE_ID,
} from "@/config/digital/services.config";
import { queryXorPayOrderStatus } from "@/app/digital/lib/payment/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/digital/lib/payment/zpayStatus";
function parseUserIdFromOrderId(orderId: string): string | null {
if (!orderId || !orderId.includes("_order_")) return null;
const prefix = orderId.split("_order_")[0];
if (!prefix || !prefix.includes("_")) return prefix || null;
const parts = prefix.split("_");
const type = parts[parts.length - 1];
if (["vip", "video", "meetup", "book"].includes(type?.toLowerCase() || "")) {
return parts.slice(0, -1).join("_") || null;
}
return prefix;
}
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
for (const path of paths) {
const res = await fetch(`${pb.url}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (res.ok) {
const data = await res.json();
if (data?.token) return data.token;
}
}
return null;
}
function resolvePayApiUrl(request: NextRequest): string {
const payConfig = getPaymentConfig();
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
return (
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
payConfig.apiUrl
).replace(/\/$/, "");
}
async function verifyOrderPaid(request: NextRequest, orderId: string): Promise<boolean> {
const apiUrl = resolvePayApiUrl(request);
const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
try {
const res = await fetch(url, { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (data?.paid) return true;
} catch (e) {
console.error("[pay/confirm] verifyOrderPaid fetch error:", e);
}
if (await queryXorPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
if (await queryZPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
return false;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const orderId = String(body?.order_id ?? body?.orderId ?? "").trim();
let userId = String(body?.user_id ?? body?.userId ?? "").trim();
if (!orderId) {
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
}
if (!userId) {
userId = parseUserIdFromOrderId(orderId) || "";
}
if (!userId) {
return NextResponse.json({ ok: false, error: "缺少 user_id 且无法从 order_id 解析" }, { status: 400 });
}
const paid = await verifyOrderPaid(request, orderId);
if (!paid) {
return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 });
}
const adminToken = await getAdminToken();
if (!adminToken) {
return NextResponse.json({ ok: false, error: "PocketBase 未配置" }, { status: 500 });
}
const pb = getPocketBaseConfig();
const joinConfig = getJoinPaymentConfig();
const totalFee = Number(joinConfig?.amount ?? 8800);
const checkPayments = await fetch(
`${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const paymentsData = await checkPayments.json().catch(() => ({}));
if ((paymentsData?.items?.length ?? 0) === 0) {
await fetch(`${pb.url}/api/collections/payments/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
user_id: userId,
type: "video",
order_id: orderId,
total_fee: totalFee,
amount: totalFee,
channel: "wxpay",
}),
});
}
const siteVipCheck = await fetch(
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(`user_id="${userId}" && site_id="${SITE_ID}"`)}&perPage=1`,
{ headers: { Authorization: `Bearer ${adminToken}` } }
);
const siteVipData = await siteVipCheck.json().catch(() => ({}));
const siteVipItems = siteVipData?.items ?? [];
if (siteVipItems.length > 0) {
await fetch(`${pb.url}/api/collections/site_vip/records/${siteVipItems[0].id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({ order_id: orderId }),
});
} else {
const siteVipRes = await fetch(`${pb.url}/api/collections/site_vip/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
user_id: userId,
site_id: SITE_ID,
order_id: orderId,
}),
});
if (!siteVipRes.ok) {
const err = await siteVipRes.json().catch(() => ({}));
if (!err?.data?.order_id?.message?.includes("unique") && !err?.message?.includes("unique")) {
console.error("[pay/confirm] site_vip create failed:", await siteVipRes.text());
}
}
}
return NextResponse.json({ ok: true });
} catch (e) {
console.error("[pay/confirm] error:", e);
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : "确认失败" },
{ status: 500 }
);
}
}

View File

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

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import {
getApiUrlFromRequest,
getPaymentConfig,
} from "@/config/digital/services.config";
import { queryXorPayOrderStatus } from "@/app/digital/lib/payment/xorpayStatus";
import { queryZPayOrderStatus } from "@/app/digital/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}/digital/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 });
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { uploadBuffer, generateObjectKey } from "@/app/digital/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 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/digital/services.config";
const COOKIE_NAME = "pb_session";
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
for (const path of paths) {
const res = await fetch(`${pb.url}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (res.ok) {
const data = await res.json();
if (data?.token) return data.token;
}
}
return null;
}
/** 检查单条 site_vip 是否有效 */
function isValidVip(record: { expires_at?: string | null } | null): boolean {
if (!record) return false;
const now = new Date().toISOString();
return !(record.expires_at && record.expires_at < now);
}
export async function GET(request: NextRequest) {
const token = await getAdminToken();
if (!token) return NextResponse.json({ ok: true, vip: false });
let userId: string | null = null;
// 优先用邮箱查Header 传 email更可靠
const email = request.nextUrl.searchParams.get("email")?.trim();
if (email) {
const pb = getPocketBaseConfig();
const filter = `email="${email.replace(/"/g, '\\"')}"`;
const collections = [pb.usersCollection, "_pb_users_auth_"];
for (const coll of collections) {
const userRes = await fetch(
`${pb.url}/api/collections/${coll}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (userRes.ok) {
const userData = await userRes.json();
userId = userData?.items?.[0]?.id ?? null;
if (userId) break;
}
}
}
// 无邮箱或未查到:回退到 cookie 中的 userId
if (!userId) {
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"));
userId = payload?.userId ?? null;
} catch {
return NextResponse.json({ ok: true, vip: false });
}
}
if (!userId) return NextResponse.json({ ok: true, vip: false });
try {
const pb = getPocketBaseConfig();
// 1) 本专题站 VIPsite_id = SITE_ID2) vip 站 master VIPsite_id = vip可解锁所有 digital 专题站
const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`;
const res = await fetch(
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=2&sort=-expires_at`,
{ 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.find((r: { expires_at?: string | null }) => isValidVip(r));
const vip = !!record;
return NextResponse.json({
ok: true,
vip,
expires_at: record?.expires_at ?? null,
});
} catch {
return NextResponse.json({ ok: true, vip: false });
}
}