346 lines
8.3 KiB
TypeScript
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);
|
|
}
|
|
}
|