216 lines
5.5 KiB
TypeScript
216 lines
5.5 KiB
TypeScript
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<string, string> = {};
|
|
|
|
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<string, unknown>
|
|
| 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: `DuluPay 充值 (${paidOrder.outTradeNo})`,
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|