/** * 支付确认:payjsapi 未写入时,由 nomadvip 直接写入 PocketBase + 创建 Memos */ import { NextRequest, NextResponse } from "next/server"; import { getApiUrlFromRequest, getPocketBaseConfig, getPaymentConfig, SITE_ID, } from "@/config/services.config"; import { queryXorPayOrderStatus } from "@/app/lib/xorpayStatus"; import { queryZPayOrderStatus } from "@/app/lib/zpayStatus"; const getDefaultTotalFee = () => getPaymentConfig().defaultAmount ?? Number(process.env.PAYMENT_DEFAULT_AMOUNT) ?? 8800; function parsePayPriceToCents(payPrice: unknown): number | null { if (payPrice == null) return null; const raw = String(payPrice).trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value <= 0) return null; return Math.round(value * 100); } async function getAdminToken(): Promise { const email = process.env.POCKETBASE_EMAIL; const password = process.env.POCKETBASE_PASSWORD; if (!email || !password) return null; const pb = getPocketBaseConfig(); const 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<{ paid: boolean; totalFeeCents: number | null }> { const apiUrl = resolvePayApiUrl(request); const url = `${apiUrl}/nomadvip/order_status?order_id=${encodeURIComponent(orderId)}`; console.log("[pay/confirm] verify order_status url=%s", 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; const totalFeeCents = parsePayPriceToCents(data?.pay_price); if (!paid && !res.ok) { console.warn("[pay/confirm] order_status non-ok:", res.status, url, data); } if (paid) { return { paid: true, totalFeeCents }; } } catch (e) { console.error("[pay/confirm] verifyOrderPaid fetch error:", e); } const xorpay = await queryXorPayOrderStatus(orderId, 5000); console.log( "[pay/confirm] 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 { paid: true, totalFeeCents: parsePayPriceToCents(xorpay?.pay_price), }; } const zpay = await queryZPayOrderStatus(orderId, 5000); console.log( "[pay/confirm] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s", orderId, !!zpay?.paid, zpay?.status || "", zpay?.error || "", zpay?.url || "" ); return { paid: !!zpay?.paid, totalFeeCents: parsePayPriceToCents(zpay?.pay_price), }; } async function createMemosUser(username: string, password: string): Promise { const baseUrl = process.env.MEMOS_API_URL || "https://qun.hackrobot.cn"; const token = process.env.MEMOS_ACCESS_TOKEN; if (!token) return false; const url = `${baseUrl.replace(/\/$/, "")}/api/v1/users`; try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ username, password, role: "USER", state: "NORMAL", }), }); if (res.ok) return true; const err = await res.json().catch(() => ({})); const alreadyExists = err?.code === 6 || err?.code === 13 || err?.message?.includes("already exists") || err?.message?.includes("UNIQUE") || err?.message?.includes("constraint"); if (alreadyExists) return true; console.error("Memos create user failed:", res.status, err); return false; } catch (e) { console.error("Memos create user error:", e); return false; } } export async function POST(request: NextRequest) { try { const body = await request.json().catch(() => ({})); const orderId = String(body?.order_id ?? body?.orderId ?? "").trim(); const userId = String(body?.user_id ?? body?.userId ?? "").trim(); console.log("[pay/confirm] 请求 order_id=%s user_id=%s", orderId || "(空)", userId || "(空)"); if (!orderId || !userId) { return NextResponse.json({ ok: false, error: "缺少 order_id 或 user_id" }, { status: 400 }); } const verifyResult = await verifyOrderPaid(request, orderId); if (!verifyResult.paid) { console.error("[pay/confirm] 订单未支付 order_id=%s user_id=%s (请检查 payjsapi order_status 接口)", orderId, userId); 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 totalFee = verifyResult.totalFeeCents ?? getDefaultTotalFee(); const checkExisting = await fetch( `${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`, { headers: { Authorization: `Bearer ${adminToken}` } } ); const existingData = await checkExisting.json().catch(() => ({})); const paymentsExist = (existingData?.items?.length ?? 0) > 0; if (!paymentsExist) { const paymentsRes = 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: "vip", order_id: orderId, total_fee: totalFee, amount: totalFee, channel: "wxpay", }), }); if (!paymentsRes.ok) { const err = await paymentsRes.json().catch(() => ({})); if (err?.data?.order_id?.message?.includes("unique") || err?.message?.includes("unique")) { // 已存在(并发),继续 } else { const msg = err?.message || err?.data ? JSON.stringify(err) : await paymentsRes.text(); console.error("[pay/confirm] payments create failed:", paymentsRes.status, msg); return NextResponse.json( { ok: false, error: "写入 payments 失败", detail: msg }, { status: 500 } ); } } } 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")) { // 已存在,继续 } else { console.error("site_vip create failed:", await siteVipRes.text()); } } } if (userId.startsWith("user") && /^user\d+$/.test(userId)) { await createMemosUser(userId, "12345678"); } console.log("[pay/confirm] 成功 order_id=%s user_id=%s", orderId, userId); 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 } ); } }