Your Name f874b3464f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add wechat pay channel to unified billing flow
2026-02-12 13:32:13 +08:00

346 lines
8.3 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
getMonthlyImageLimitByPlanId,
getWechatPayConfig,
isBillingPlanId,
type BillingPlanId,
} from "@/lib/billing/plans";
import { parseWechatNotifyPayload, verifyWechatPaySignature } from "@/lib/billing/wechat";
import { db } from "@/lib/db";
import { systemSettings, user } from "@/lib/db/schema";
type NotifyObject = {
out_trade_no?: string;
trade_state?: string;
trade_state_desc?: string;
transaction_id?: string;
attach?: string;
success_time?: string;
};
function notifySuccess() {
return NextResponse.json({ code: "SUCCESS", message: "成功" });
}
function notifyFail(message: string, status = 400) {
return NextResponse.json({ code: "FAIL", message }, { status });
}
function getEventKey(id: string) {
return `wechat:pay:event:${id}`;
}
async function lockEvent(id: string, type: string) {
const key = getEventKey(id);
const now = new Date();
const [inserted] = await db
.insert(systemSettings)
.values({
key,
value: {
id,
type,
status: "processing",
occurredAt: now.toISOString(),
attempts: 1,
},
updatedAt: now,
updatedBy: null,
})
.onConflictDoNothing()
.returning({ id: systemSettings.id });
if (inserted) {
return { key, shouldProcess: true as const };
}
const [existing] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1);
const value = existing?.value as Record<string, unknown> | undefined;
const status = typeof value?.status === "string" ? value.status : "unknown";
const attempts = typeof value?.attempts === "number" ? value.attempts : 1;
if (status === "processed") {
return { key, shouldProcess: false as const };
}
await db
.update(systemSettings)
.set({
value: {
...(value || {}),
status: "processing",
attempts: attempts + 1,
},
updatedAt: now,
updatedBy: null,
})
.where(eq(systemSettings.key, key));
return { key, shouldProcess: true as const };
}
async function finalizeEvent({
key,
status,
payload,
}: {
key: string;
status: "processed" | "failed";
payload: Record<string, unknown>;
}) {
const now = new Date();
const [existing] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1);
const value = existing?.value as Record<string, unknown> | undefined;
await db
.update(systemSettings)
.set({
value: {
...(value || {}),
...payload,
status,
updatedAt: now.toISOString(),
},
updatedAt: now,
updatedBy: null,
})
.where(eq(systemSettings.key, key));
}
async function updateOrderRecord({
outTradeNo,
payload,
}: {
outTradeNo: string;
payload: Record<string, unknown>;
}) {
const key = `wechat:pay:order:${outTradeNo}`;
const now = new Date();
const [existing] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1);
const value = existing?.value as Record<string, unknown> | undefined;
if (!existing) {
await db.insert(systemSettings).values({
key,
value: {
outTradeNo,
...payload,
updatedAt: now.toISOString(),
},
updatedAt: now,
updatedBy: null,
});
return;
}
await db
.update(systemSettings)
.set({
value: {
...(value || {}),
...payload,
updatedAt: now.toISOString(),
},
updatedAt: now,
updatedBy: null,
})
.where(eq(systemSettings.key, key));
}
function parseAttach(attach: unknown) {
if (typeof attach !== "string" || !attach) {
return {} as { userId?: string; email?: string; planId?: string };
}
try {
const parsed = JSON.parse(attach) as Record<string, unknown>;
return {
userId: typeof parsed.userId === "string" ? parsed.userId : undefined,
email: typeof parsed.email === "string" ? parsed.email : undefined,
planId: typeof parsed.planId === "string" ? parsed.planId : undefined,
};
} catch {
return {} as { userId?: string; email?: string; planId?: string };
}
}
async function applyPlanToUser({
userId,
email,
planId,
}: {
userId?: string;
email?: string;
planId: BillingPlanId;
}) {
const monthlyImageLimit = getMonthlyImageLimitByPlanId(planId);
if (userId) {
await db
.update(user)
.set({
monthlyImageLimit,
updatedAt: new Date(),
})
.where(eq(user.id, userId));
return;
}
if (email) {
await db
.update(user)
.set({
monthlyImageLimit,
updatedAt: new Date(),
})
.where(and(eq(user.email, email), eq(user.isActive, true)));
}
}
export async function POST(request: Request) {
const config = getWechatPayConfig();
if (!config) {
return notifyFail("wechat pay is not configured", 503);
}
const body = await request.text();
const timestamp = request.headers.get("wechatpay-timestamp") || "";
const nonce = request.headers.get("wechatpay-nonce") || "";
const signature = request.headers.get("wechatpay-signature") || "";
if (!timestamp || !nonce || !signature) {
return notifyFail("missing wechat signature headers");
}
const verified = verifyWechatPaySignature({
body,
timestamp,
nonce,
signature,
platformPublicKey: config.platformPublicKey,
});
if (!verified) {
return notifyFail("invalid signature", 401);
}
try {
const { envelope, transaction } = parseWechatNotifyPayload({
body,
apiV3Key: config.apiV3Key,
});
const eventLock = await lockEvent(envelope.id, envelope.event_type);
if (!eventLock.shouldProcess) {
return notifySuccess();
}
const order = transaction as NotifyObject;
const outTradeNo = order.out_trade_no;
if (!outTradeNo) {
await finalizeEvent({
key: eventLock.key,
status: "failed",
payload: {
id: envelope.id,
type: envelope.event_type,
detail: "missing_out_trade_no",
},
});
return notifyFail("missing out_trade_no");
}
const orderKey = `wechat:pay:order:${outTradeNo}`;
const [orderSetting] = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, orderKey))
.limit(1);
const orderValue = orderSetting?.value as Record<string, unknown> | undefined;
const attach = parseAttach(order.attach);
const userId = attach.userId || (typeof orderValue?.userId === "string" ? orderValue.userId : undefined);
const email = attach.email || (typeof orderValue?.email === "string" ? orderValue.email : undefined);
const planId = attach.planId || (typeof orderValue?.planId === "string" ? orderValue.planId : undefined);
const tradeState = order.trade_state || "UNKNOWN";
if (tradeState === "SUCCESS" && planId && isBillingPlanId(planId)) {
await applyPlanToUser({
userId,
email,
planId,
});
}
await updateOrderRecord({
outTradeNo,
payload: {
status: tradeState === "SUCCESS" ? "paid" : tradeState.toLowerCase(),
transactionId: order.transaction_id,
paidAt: order.success_time,
detail: order.trade_state_desc,
userId,
email,
planId,
},
});
await finalizeEvent({
key: eventLock.key,
status: "processed",
payload: {
id: envelope.id,
type: envelope.event_type,
outTradeNo,
tradeState,
planId,
userId,
email,
},
});
return notifySuccess();
} catch (error) {
try {
const parsed = JSON.parse(body) as { id?: string; event_type?: string };
if (parsed.id) {
await finalizeEvent({
key: getEventKey(parsed.id),
status: "failed",
payload: {
id: parsed.id,
type: parsed.event_type || "unknown",
detail: error instanceof Error ? error.message : "wechat_notify_error",
},
});
}
} catch {
// ignore
}
console.error("[Billing WeChat Notify API] Error:", error);
return notifyFail("notify process failed", 500);
}
}