import { NextRequest, NextResponse } from "next/server"; import { and, eq, ne, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { paymentOrder, pointsLedger, systemSettings, user } from "@/lib/db/schema"; import { DULUPAY_CONFIG_KEY, normalizeDulupayConfig, } from "@/lib/payment/dulupay-config"; import { verifyDulupayV1Sign } from "@/lib/payment/dulupay"; export const runtime = "nodejs"; function plainText(text: string, status = 200) { return new NextResponse(text, { status, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", }, }); } async function readNotifyParams(request: NextRequest) { const url = new URL(request.url); const record: Record = {}; url.searchParams.forEach((value, key) => { record[key] = value; }); if (request.method === "GET") { return record; } const contentType = request.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const data = (await request.json().catch(() => null)) as | Record | null; if (data) { for (const [key, value] of Object.entries(data)) { if (value !== undefined && value !== null) { record[key] = String(value); } } } return record; } const text = await request.text(); if (!text) return record; const formParams = new URLSearchParams(text); formParams.forEach((value, key) => { record[key] = value; }); return record; } async function handleNotify(request: NextRequest) { const params = await readNotifyParams(request); const outTradeNo = String(params.out_trade_no || "").trim(); const tradeNo = String(params.trade_no || "").trim(); const tradeStatus = String(params.trade_status || "").trim(); const moneyRaw = String(params.money || "").trim(); if (!outTradeNo) { return plainText("fail", 400); } const setting = await db .select() .from(systemSettings) .where(eq(systemSettings.key, DULUPAY_CONFIG_KEY)) .limit(1) .then((rows) => rows[0]); const config = normalizeDulupayConfig(setting?.value); if (!config.enabled || config.version !== "v1" || !config.key) { return plainText("fail", 400); } if (!verifyDulupayV1Sign(params, config.key)) { return plainText("fail", 400); } const order = await db .select({ id: paymentOrder.id, userId: paymentOrder.userId, status: paymentOrder.status, points: paymentOrder.points, amountCents: paymentOrder.amountCents, }) .from(paymentOrder) .where( and( eq(paymentOrder.outTradeNo, outTradeNo), eq(paymentOrder.provider, "dulupay") ) ) .limit(1) .then((rows) => rows[0]); if (!order) { return plainText("fail", 404); } const callbackAmountCents = Number.isFinite(Number(moneyRaw)) ? Math.round(Number(moneyRaw) * 100) : null; if ( callbackAmountCents !== null && callbackAmountCents > 0 && callbackAmountCents !== order.amountCents ) { await db .update(paymentOrder) .set({ status: "failed", tradeNo: tradeNo || null, errorMessage: `回调金额不一致: ${moneyRaw}`, rawCallback: params, updatedAt: new Date(), }) .where(and(eq(paymentOrder.id, order.id), ne(paymentOrder.status, "paid"))); return plainText("fail", 400); } if (tradeStatus === "TRADE_SUCCESS") { await db.transaction(async (tx) => { const updated = await tx .update(paymentOrder) .set({ status: "paid", tradeNo: tradeNo || null, rawCallback: params, errorMessage: null, paidAt: new Date(), updatedAt: new Date(), }) .where( and(eq(paymentOrder.id, order.id), ne(paymentOrder.status, "paid")) ) .returning({ id: paymentOrder.id, userId: paymentOrder.userId, points: paymentOrder.points, outTradeNo: paymentOrder.outTradeNo, }); if (updated.length === 0) { return; } const paidOrder = updated[0]; if (paidOrder.points > 0) { await tx .update(user) .set({ pointsBalance: sql`${user.pointsBalance} + ${paidOrder.points}`, updatedAt: new Date(), }) .where(eq(user.id, paidOrder.userId)); await tx.insert(pointsLedger).values({ userId: paidOrder.userId, amount: paidOrder.points, reason: "recharge", note: "充值积分", }); } }); return plainText("success"); } const mappedStatus = tradeStatus === "TRADE_CLOSED" ? "closed" : "failed"; await db .update(paymentOrder) .set({ status: mappedStatus, tradeNo: tradeNo || null, errorMessage: tradeStatus ? `trade_status=${tradeStatus}` : "支付失败", rawCallback: params, updatedAt: new Date(), }) .where(and(eq(paymentOrder.id, order.id), ne(paymentOrder.status, "paid"))); return plainText("success"); } export async function GET(request: NextRequest) { try { return await handleNotify(request); } catch (error) { console.error("[DuluPay Notify] GET error:", error); return plainText("fail", 500); } } export async function POST(request: NextRequest) { try { return await handleNotify(request); } catch (error) { console.error("[DuluPay Notify] POST error:", error); return plainText("fail", 500); } }