Your Name 35c4c02579
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: simplify recharge copy
2026-04-27 22:57:02 +08:00

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: "充值积分",
});
}
});
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);
}
}