268 lines
8.9 KiB
TypeScript
268 lines
8.9 KiB
TypeScript
/**
|
||
* 支付确认: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 }
|
||
);
|
||
}
|
||
}
|