Files
gitlab-instance-0a899031_no…/app/api/pay/confirm/route.ts
2026-04-01 02:23:17 -05:00

268 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 支付确认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<string | null> {
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<boolean> {
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 }
);
}
}