feat: add wechat pay channel to unified billing flow
This commit is contained in:
parent
7bcab25a7c
commit
f874b3464f
10
.env.example
10
.env.example
@ -53,3 +53,13 @@ STRIPE_BILLING_RETURN_URL=https://your-domain.com/billing
|
|||||||
STRIPE_PRICE_ID_BASIC=price_xxx_basic
|
STRIPE_PRICE_ID_BASIC=price_xxx_basic
|
||||||
STRIPE_PRICE_ID_PRO=price_xxx_pro
|
STRIPE_PRICE_ID_PRO=price_xxx_pro
|
||||||
STRIPE_PRICE_ID_ULTIMATE=price_xxx_ultimate
|
STRIPE_PRICE_ID_ULTIMATE=price_xxx_ultimate
|
||||||
|
|
||||||
|
# 微信支付(Native)配置
|
||||||
|
WECHAT_PAY_APP_ID=wx_xxx
|
||||||
|
WECHAT_PAY_MCH_ID=190000xxxx
|
||||||
|
WECHAT_PAY_PRIVATE_KEY=\"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\"
|
||||||
|
WECHAT_PAY_SERIAL_NO=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
WECHAT_PAY_API_V3_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
WECHAT_PAY_PLATFORM_PUBLIC_KEY=\"-----BEGIN PUBLIC KEY-----\\n...\\n-----END PUBLIC KEY-----\"
|
||||||
|
# 可选:回调地址(默认自动使用 /api/billing/wechat/notify)
|
||||||
|
WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/billing/wechat/notify
|
||||||
|
|||||||
107
app/api/billing/wechat/checkout/route.ts
Normal file
107
app/api/billing/wechat/checkout/route.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
getPaidPlanAmountFen,
|
||||||
|
getPaidPlanById,
|
||||||
|
getWechatPayConfig,
|
||||||
|
isPaidBillingPlanId,
|
||||||
|
type PaidBillingPlanId,
|
||||||
|
} from "@/lib/billing/plans";
|
||||||
|
import { createWechatNativeOrder, generateWechatOutTradeNo, WechatPayError } from "@/lib/billing/wechat";
|
||||||
|
import { upsertSystemSetting } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
planId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getBaseUrl(request: NextRequest) {
|
||||||
|
return (
|
||||||
|
process.env.AUTH_URL ??
|
||||||
|
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id || !session.user.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.type === "guest") {
|
||||||
|
return NextResponse.json({ error: "Guest user cannot checkout" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json());
|
||||||
|
if (!parsed.success || !isPaidBillingPlanId(parsed.data.planId)) {
|
||||||
|
return NextResponse.json({ error: "Invalid plan ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getWechatPayConfig();
|
||||||
|
if (!config) {
|
||||||
|
return NextResponse.json({ error: "WeChat Pay is not configured" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const planId = parsed.data.planId as PaidBillingPlanId;
|
||||||
|
const plan = getPaidPlanById(planId);
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return NextResponse.json({ error: "Plan not found" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outTradeNo = generateWechatOutTradeNo();
|
||||||
|
const amountFen = getPaidPlanAmountFen(planId);
|
||||||
|
const notifyUrl = config.notifyUrl || new URL("/api/billing/wechat/notify", getBaseUrl(request)).toString();
|
||||||
|
|
||||||
|
const attachPayload = JSON.stringify({
|
||||||
|
planId,
|
||||||
|
userId: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await createWechatNativeOrder({
|
||||||
|
appId: config.appId,
|
||||||
|
mchId: config.mchId,
|
||||||
|
privateKey: config.privateKey,
|
||||||
|
serialNo: config.serialNo,
|
||||||
|
description: `Lovart ${plan.name} Subscription`,
|
||||||
|
outTradeNo,
|
||||||
|
notifyUrl,
|
||||||
|
amountFen,
|
||||||
|
attach: attachPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
await upsertSystemSetting({
|
||||||
|
key: `wechat:pay:order:${outTradeNo}`,
|
||||||
|
value: {
|
||||||
|
outTradeNo,
|
||||||
|
channel: "wechat",
|
||||||
|
status: "created",
|
||||||
|
planId,
|
||||||
|
userId: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
codeUrl: order.code_url,
|
||||||
|
amountFen,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrCodeImageUrl = `https://api.qrserver.com/v1/create-qr-code/?size=260x260&data=${encodeURIComponent(order.code_url)}`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
outTradeNo,
|
||||||
|
codeUrl: order.code_url,
|
||||||
|
qrCodeImageUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WechatPayError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[Billing WeChat Checkout API] Error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create wechat pay order" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
345
app/api/billing/wechat/notify/route.ts
Normal file
345
app/api/billing/wechat/notify/route.ts
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/api/billing/wechat/order-status/route.ts
Normal file
40
app/api/billing/wechat/order-status/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id || !session.user.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outTradeNo = request.nextUrl.searchParams.get("outTradeNo");
|
||||||
|
|
||||||
|
if (!outTradeNo) {
|
||||||
|
return NextResponse.json({ error: "Missing outTradeNo" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({ key: `wechat:pay:order:${outTradeNo}` });
|
||||||
|
|
||||||
|
if (!setting || !setting.value || typeof setting.value !== "object") {
|
||||||
|
return NextResponse.json({ error: "Order not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = setting.value as Record<string, unknown>;
|
||||||
|
const userId = typeof value.userId === "string" ? value.userId : null;
|
||||||
|
const email = typeof value.email === "string" ? value.email : null;
|
||||||
|
|
||||||
|
if (userId !== session.user.id && email?.toLowerCase() !== session.user.email.toLowerCase()) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
outTradeNo,
|
||||||
|
status: typeof value.status === "string" ? value.status : "unknown",
|
||||||
|
planId: typeof value.planId === "string" ? value.planId : null,
|
||||||
|
transactionId: typeof value.transactionId === "string" ? value.transactionId : null,
|
||||||
|
paidAt: typeof value.paidAt === "string" ? value.paidAt : null,
|
||||||
|
detail: typeof value.detail === "string" ? value.detail : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,9 +5,10 @@ import { auth } from "@/app/(auth)/auth";
|
|||||||
import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button";
|
import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button";
|
||||||
import {
|
import {
|
||||||
getCheckoutEnabledPlans,
|
getCheckoutEnabledPlans,
|
||||||
|
isWechatPayEnabled,
|
||||||
resolvePlanByMonthlyImageLimit,
|
resolvePlanByMonthlyImageLimit,
|
||||||
} from "@/lib/billing/plans";
|
} from "@/lib/billing/plans";
|
||||||
import { getStripeWebhookEventsByUser, getUserById } from "@/lib/db/queries";
|
import { getBillingTimelineByUser, getUserById } from "@/lib/db/queries";
|
||||||
|
|
||||||
function formatLimit(limit: number | null) {
|
function formatLimit(limit: number | null) {
|
||||||
if (limit == null || limit === 0) {
|
if (limit == null || limit === 0) {
|
||||||
@ -45,6 +46,13 @@ function formatEventType(type: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatChannel(channel: string) {
|
||||||
|
if (channel === "wechat") {
|
||||||
|
return "微信";
|
||||||
|
}
|
||||||
|
return "Stripe";
|
||||||
|
}
|
||||||
|
|
||||||
export default async function BillingPage() {
|
export default async function BillingPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
@ -58,13 +66,14 @@ export default async function BillingPage() {
|
|||||||
redirect("/login?callbackUrl=/billing");
|
redirect("/login?callbackUrl=/billing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [billingEvents, checkoutEnabledPlans] = await Promise.all([
|
const [billingEvents, checkoutEnabledPlans, wechatEnabled] = await Promise.all([
|
||||||
getStripeWebhookEventsByUser({
|
getBillingTimelineByUser({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
limit: 12,
|
limit: 12,
|
||||||
}),
|
}),
|
||||||
Promise.resolve(getCheckoutEnabledPlans()),
|
Promise.resolve(getCheckoutEnabledPlans()),
|
||||||
|
Promise.resolve(isWechatPayEnabled()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit);
|
const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit);
|
||||||
@ -117,6 +126,10 @@ export default async function BillingPage() {
|
|||||||
<ShieldCheck className="h-4 w-4 text-cyan-300" />
|
<ShieldCheck className="h-4 w-4 text-cyan-300" />
|
||||||
已配置可下单套餐:{checkoutAvailableCount}/3
|
已配置可下单套餐:{checkoutAvailableCount}/3
|
||||||
</p>
|
</p>
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-cyan-300" />
|
||||||
|
微信支付通道:{wechatEnabled ? "已启用" : "未启用"}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-zinc-400">
|
<p className="text-xs text-zinc-400">
|
||||||
订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。
|
订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。
|
||||||
</p>
|
</p>
|
||||||
@ -148,6 +161,7 @@ export default async function BillingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-zinc-400">
|
<p className="mt-1 text-[11px] text-zinc-400">
|
||||||
|
{formatChannel(event.channel)} ·{" "}
|
||||||
{formatDateTime(event.occurredAt)}
|
{formatDateTime(event.occurredAt)}
|
||||||
{event.planId ? ` · ${event.planId}` : ""}
|
{event.planId ? ` · ${event.planId}` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Check, Rocket, ShieldCheck } from "lucide-react";
|
import { Check, Rocket, ShieldCheck } from "lucide-react";
|
||||||
import { getCheckoutEnabledPlans } from "@/lib/billing/plans";
|
import { getCheckoutEnabledPlans, isWechatPayEnabled } from "@/lib/billing/plans";
|
||||||
import { MarketingShell } from "@/components/lovart/marketing-shell";
|
import { MarketingShell } from "@/components/lovart/marketing-shell";
|
||||||
import { PricingActionButton } from "@/components/lovart/pricing-action-button";
|
import { PricingActionButton } from "@/components/lovart/pricing-action-button";
|
||||||
|
import { WechatPricingButton } from "@/components/lovart/wechat-pricing-button";
|
||||||
import { Reveal } from "@/components/lovart/reveal";
|
import { Reveal } from "@/components/lovart/reveal";
|
||||||
import {
|
import {
|
||||||
getEnabledAiModelsWithProvider,
|
getEnabledAiModelsWithProvider,
|
||||||
@ -42,6 +43,7 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
|
|||||||
const modelCount = models.length;
|
const modelCount = models.length;
|
||||||
const tutorialCount = videos.length;
|
const tutorialCount = videos.length;
|
||||||
const checkoutEnabledPlans = getCheckoutEnabledPlans();
|
const checkoutEnabledPlans = getCheckoutEnabledPlans();
|
||||||
|
const wechatEnabled = isWechatPayEnabled();
|
||||||
|
|
||||||
const plans: PlanViewModel[] = [
|
const plans: PlanViewModel[] = [
|
||||||
{
|
{
|
||||||
@ -186,12 +188,15 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
|
|||||||
选择 {plan.name}
|
选择 {plan.name}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<PricingActionButton
|
<div className="mt-6 space-y-2">
|
||||||
checkoutEnabled={checkoutEnabledPlans[plan.id]}
|
<PricingActionButton
|
||||||
highlight={plan.highlight}
|
checkoutEnabled={checkoutEnabledPlans[plan.id]}
|
||||||
planId={plan.id}
|
highlight={plan.highlight}
|
||||||
planName={plan.name}
|
planId={plan.id}
|
||||||
/>
|
planName={plan.name}
|
||||||
|
/>
|
||||||
|
<WechatPricingButton enabled={wechatEnabled} planId={plan.id} planName={plan.name} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@ -213,7 +218,7 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<ShieldCheck className="mt-0.5 h-4 w-4 text-cyan-300" />
|
<ShieldCheck className="mt-0.5 h-4 w-4 text-cyan-300" />
|
||||||
支持接入 Stripe / 微信支付,并保留当前视觉样式。
|
已支持 Stripe + 微信支付双通道,并统一在订阅中心管理。
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
202
components/lovart/wechat-pricing-button.tsx
Normal file
202
components/lovart/wechat-pricing-button.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Copy, Loader2, QrCode } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export function WechatPricingButton({
|
||||||
|
planId,
|
||||||
|
planName,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
planId: string;
|
||||||
|
planName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [outTradeNo, setOutTradeNo] = useState<string | null>(null);
|
||||||
|
const [qrCodeImageUrl, setQrCodeImageUrl] = useState<string | null>(null);
|
||||||
|
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||||
|
const [statusText, setStatusText] = useState("请使用微信扫码完成支付");
|
||||||
|
|
||||||
|
const canPoll = useMemo(() => open && Boolean(outTradeNo), [open, outTradeNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canPoll || !outTradeNo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: number | undefined;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/billing/wechat/order-status?outTradeNo=${encodeURIComponent(outTradeNo)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { status?: string };
|
||||||
|
if (stopped || !data.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === "paid") {
|
||||||
|
setStatusText("支付成功,正在跳转...");
|
||||||
|
stopped = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/lovart/pricing?checkout=success&plan=${encodeURIComponent(planId)}`);
|
||||||
|
}, 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status !== "created" && data.status !== "processing") {
|
||||||
|
setStatusText(`当前状态:${data.status}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore polling failure
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopped) {
|
||||||
|
timer = window.setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timer) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [canPoll, outTradeNo, planId, router]);
|
||||||
|
|
||||||
|
async function handleCheckout() {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
window.alert("微信支付尚未配置,请先配置微信支付环境变量。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user || session.user.type === "guest") {
|
||||||
|
router.push("/login?callbackUrl=/lovart/pricing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/billing/wechat/checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ planId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
error?: string;
|
||||||
|
outTradeNo?: string;
|
||||||
|
qrCodeImageUrl?: string;
|
||||||
|
codeUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !data.outTradeNo || !data.qrCodeImageUrl || !data.codeUrl) {
|
||||||
|
window.alert(data.error || "创建微信支付订单失败,请稍后重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutTradeNo(data.outTradeNo);
|
||||||
|
setQrCodeImageUrl(data.qrCodeImageUrl);
|
||||||
|
setCodeUrl(data.codeUrl);
|
||||||
|
setStatusText("请使用微信扫码完成支付");
|
||||||
|
setOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WechatPricingButton] checkout error", error);
|
||||||
|
window.alert("创建微信支付订单失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyLink() {
|
||||||
|
if (!codeUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(codeUrl);
|
||||||
|
window.alert("支付链接已复制");
|
||||||
|
} catch {
|
||||||
|
window.alert("复制失败,请手动复制");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex w-full items-center justify-center gap-2 rounded-xl border border-emerald-300/30 bg-emerald-500/10 px-4 py-2 text-sm font-semibold text-emerald-100 hover:border-emerald-300/60",
|
||||||
|
isPending && "cursor-not-allowed opacity-75"
|
||||||
|
)}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleCheckout}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <QrCode className="h-4 w-4" />}
|
||||||
|
微信支付 {planName}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-sm border-white/10 bg-[#0a1120] text-zinc-100">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>微信扫码支付</DialogTitle>
|
||||||
|
<DialogDescription className="text-zinc-300">{statusText}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{qrCodeImageUrl ? (
|
||||||
|
<div className="mx-auto w-fit rounded-xl bg-white p-2">
|
||||||
|
<img alt="wechat pay qrcode" className="h-56 w-56" src={qrCodeImageUrl} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto flex h-56 w-56 items-center justify-center rounded-xl border border-dashed border-white/20 text-sm text-zinc-400">
|
||||||
|
正在生成二维码...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-300">
|
||||||
|
<p>订单号:{outTradeNo || "-"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-white/20 px-3 py-2 text-xs text-zinc-100 hover:border-white/40"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
复制支付链接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,6 +25,9 @@
|
|||||||
| `POST /api/billing/checkout` | Stripe Checkout 会话创建 | 登录用户触发支付 | 已接入 |
|
| `POST /api/billing/checkout` | Stripe Checkout 会话创建 | 登录用户触发支付 | 已接入 |
|
||||||
| `POST /api/billing/webhook/stripe` | Stripe 事件回调 | 更新用户套餐额度 | 已接入 |
|
| `POST /api/billing/webhook/stripe` | Stripe 事件回调 | 更新用户套餐额度 | 已接入 |
|
||||||
| `POST /api/billing/portal` | Stripe Billing Portal 会话创建 | 登录用户管理订阅 | 已接入 |
|
| `POST /api/billing/portal` | Stripe Billing Portal 会话创建 | 登录用户管理订阅 | 已接入 |
|
||||||
|
| `POST /api/billing/wechat/checkout` | 微信 Native 下单 | 返回二维码链接 | 已接入 |
|
||||||
|
| `GET /api/billing/wechat/order-status` | 微信订单状态查询 | 轮询支付状态 | 已接入 |
|
||||||
|
| `POST /api/billing/wechat/notify` | 微信支付回调 | 更新用户套餐额度 | 已接入 |
|
||||||
| `/billing` | 用户订阅中心页面 | 查看套餐与进入账单门户 | 已接入 |
|
| `/billing` | 用户订阅中心页面 | 查看套餐与进入账单门户 | 已接入 |
|
||||||
|
|
||||||
## 新增查询与 API
|
## 新增查询与 API
|
||||||
@ -46,6 +49,9 @@
|
|||||||
- `POST /api/billing/checkout`
|
- `POST /api/billing/checkout`
|
||||||
- `POST /api/billing/webhook/stripe`
|
- `POST /api/billing/webhook/stripe`
|
||||||
- `POST /api/billing/portal`
|
- `POST /api/billing/portal`
|
||||||
|
- `POST /api/billing/wechat/checkout`
|
||||||
|
- `GET /api/billing/wechat/order-status`
|
||||||
|
- `POST /api/billing/wechat/notify`
|
||||||
|
|
||||||
## 页面层联动点
|
## 页面层联动点
|
||||||
|
|
||||||
@ -83,6 +89,13 @@
|
|||||||
- `STRIPE_PRICE_ID_BASIC`
|
- `STRIPE_PRICE_ID_BASIC`
|
||||||
- `STRIPE_PRICE_ID_PRO`
|
- `STRIPE_PRICE_ID_PRO`
|
||||||
- `STRIPE_PRICE_ID_ULTIMATE`
|
- `STRIPE_PRICE_ID_ULTIMATE`
|
||||||
|
- `WECHAT_PAY_APP_ID`
|
||||||
|
- `WECHAT_PAY_MCH_ID`
|
||||||
|
- `WECHAT_PAY_PRIVATE_KEY`
|
||||||
|
- `WECHAT_PAY_SERIAL_NO`
|
||||||
|
- `WECHAT_PAY_API_V3_KEY`
|
||||||
|
- `WECHAT_PAY_PLATFORM_PUBLIC_KEY`
|
||||||
|
- `WECHAT_PAY_NOTIFY_URL`(可选)
|
||||||
- Stripe Webhook 回调地址:
|
- Stripe Webhook 回调地址:
|
||||||
- `POST /api/billing/webhook/stripe`
|
- `POST /api/billing/webhook/stripe`
|
||||||
- 当前事件处理:
|
- 当前事件处理:
|
||||||
@ -91,7 +104,9 @@
|
|||||||
- `customer.subscription.deleted`
|
- `customer.subscription.deleted`
|
||||||
- 已增加 Webhook 幂等保护:
|
- 已增加 Webhook 幂等保护:
|
||||||
- 通过 `SystemSettings` 的 `stripe:webhook:event:*` 唯一键防重复处理。
|
- 通过 `SystemSettings` 的 `stripe:webhook:event:*` 唯一键防重复处理。
|
||||||
|
- 微信支付同样增加幂等保护:
|
||||||
|
- 使用 `SystemSettings` 的 `wechat:pay:event:*` 和 `wechat:pay:order:*` 追踪并防重。
|
||||||
- 已增加订阅事件追踪:
|
- 已增加订阅事件追踪:
|
||||||
- `/billing` 页面展示当前用户最近订阅事件(基于 webhook 处理记录)。
|
- `/billing` 页面展示 Stripe + 微信支付统一时间线。
|
||||||
- 订阅权益映射:
|
- 订阅权益映射:
|
||||||
- 基于套餐写回 `User.monthlyImageLimit`。
|
- 基于套餐写回 `User.monthlyImageLimit`。
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type BillingPlanDefinition = {
|
|||||||
price: string;
|
price: string;
|
||||||
period: string;
|
period: string;
|
||||||
monthlyImageLimit: number | null;
|
monthlyImageLimit: number | null;
|
||||||
|
cnyFen: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
|
export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
|
||||||
@ -16,6 +17,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
|
|||||||
price: "¥0",
|
price: "¥0",
|
||||||
period: "/month",
|
period: "/month",
|
||||||
monthlyImageLimit: 200,
|
monthlyImageLimit: 200,
|
||||||
|
cnyFen: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "basic",
|
id: "basic",
|
||||||
@ -23,6 +25,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
|
|||||||
price: "¥69",
|
price: "¥69",
|
||||||
period: "/month",
|
period: "/month",
|
||||||
monthlyImageLimit: 1200,
|
monthlyImageLimit: 1200,
|
||||||
|
cnyFen: 6900,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pro",
|
id: "pro",
|
||||||
@ -30,6 +33,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
|
|||||||
price: "¥199",
|
price: "¥199",
|
||||||
period: "/month",
|
period: "/month",
|
||||||
monthlyImageLimit: 6000,
|
monthlyImageLimit: 6000,
|
||||||
|
cnyFen: 19900,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ultimate",
|
id: "ultimate",
|
||||||
@ -37,6 +41,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
|
|||||||
price: "¥599",
|
price: "¥599",
|
||||||
period: "/month",
|
period: "/month",
|
||||||
monthlyImageLimit: null,
|
monthlyImageLimit: null,
|
||||||
|
cnyFen: 59900,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -64,6 +69,10 @@ export function getPaidPlanById(planId: PaidBillingPlanId) {
|
|||||||
return BILLING_PLAN_DEFINITIONS.find((plan) => plan.id === planId) || null;
|
return BILLING_PLAN_DEFINITIONS.find((plan) => plan.id === planId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPaidPlanAmountFen(planId: PaidBillingPlanId) {
|
||||||
|
return getPaidPlanById(planId)?.cnyFen ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMonthlyImageLimitByPlanId(planId: BillingPlanId) {
|
export function getMonthlyImageLimitByPlanId(planId: BillingPlanId) {
|
||||||
return getBillingPlanById(planId)?.monthlyImageLimit ?? null;
|
return getBillingPlanById(planId)?.monthlyImageLimit ?? null;
|
||||||
}
|
}
|
||||||
@ -113,3 +122,35 @@ export function getCheckoutEnabledPlans() {
|
|||||||
ultimate: false,
|
ultimate: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEnv(name: string) {
|
||||||
|
const value = process.env[name];
|
||||||
|
return value && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWechatPayConfig() {
|
||||||
|
const appId = getEnv("WECHAT_PAY_APP_ID");
|
||||||
|
const mchId = getEnv("WECHAT_PAY_MCH_ID");
|
||||||
|
const privateKey = getEnv("WECHAT_PAY_PRIVATE_KEY");
|
||||||
|
const serialNo = getEnv("WECHAT_PAY_SERIAL_NO");
|
||||||
|
const apiV3Key = getEnv("WECHAT_PAY_API_V3_KEY");
|
||||||
|
const platformPublicKey = getEnv("WECHAT_PAY_PLATFORM_PUBLIC_KEY");
|
||||||
|
|
||||||
|
if (!appId || !mchId || !privateKey || !serialNo || !apiV3Key || !platformPublicKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appId,
|
||||||
|
mchId,
|
||||||
|
privateKey,
|
||||||
|
serialNo,
|
||||||
|
apiV3Key,
|
||||||
|
platformPublicKey,
|
||||||
|
notifyUrl: getEnv("WECHAT_PAY_NOTIFY_URL"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWechatPayEnabled() {
|
||||||
|
return Boolean(getWechatPayConfig());
|
||||||
|
}
|
||||||
|
|||||||
229
lib/billing/wechat.ts
Normal file
229
lib/billing/wechat.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
createDecipheriv,
|
||||||
|
createSign,
|
||||||
|
createVerify,
|
||||||
|
randomBytes,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
|
const WECHAT_PAY_API_BASE = "https://api.mch.weixin.qq.com";
|
||||||
|
|
||||||
|
type WechatNativeOrderParams = {
|
||||||
|
appId: string;
|
||||||
|
mchId: string;
|
||||||
|
privateKey: string;
|
||||||
|
serialNo: string;
|
||||||
|
description: string;
|
||||||
|
outTradeNo: string;
|
||||||
|
notifyUrl: string;
|
||||||
|
amountFen: number;
|
||||||
|
attach?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WechatNativeOrderResponse = {
|
||||||
|
code_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WechatPayNotifyEnvelope = {
|
||||||
|
id: string;
|
||||||
|
create_time: string;
|
||||||
|
event_type: string;
|
||||||
|
resource_type: string;
|
||||||
|
summary: string;
|
||||||
|
resource: {
|
||||||
|
algorithm: string;
|
||||||
|
ciphertext: string;
|
||||||
|
associated_data?: string;
|
||||||
|
nonce: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WechatPayError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status = 500) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePem(pem: string) {
|
||||||
|
return pem.replace(/\\n/g, "\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWechatPaySignature({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
body,
|
||||||
|
privateKey,
|
||||||
|
}: {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
body: string;
|
||||||
|
privateKey: string;
|
||||||
|
}) {
|
||||||
|
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
|
||||||
|
const signer = createSign("RSA-SHA256");
|
||||||
|
signer.update(message);
|
||||||
|
signer.end();
|
||||||
|
return signer.sign(normalizePem(privateKey), "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWechatAuthorization({
|
||||||
|
mchId,
|
||||||
|
serialNo,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
signature,
|
||||||
|
}: {
|
||||||
|
mchId: string;
|
||||||
|
serialNo: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
signature: string;
|
||||||
|
}) {
|
||||||
|
return `WECHATPAY2-SHA256-RSA2048 mchid=\"${mchId}\",nonce_str=\"${nonce}\",timestamp=\"${timestamp}\",serial_no=\"${serialNo}\",signature=\"${signature}\"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateWechatOutTradeNo() {
|
||||||
|
const suffix = randomBytes(4).toString("hex");
|
||||||
|
return `LVT${Date.now()}${suffix}`.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWechatNativeOrder(params: WechatNativeOrderParams) {
|
||||||
|
const path = "/v3/pay/transactions/native";
|
||||||
|
const timestamp = `${Math.floor(Date.now() / 1000)}`;
|
||||||
|
const nonce = randomBytes(16).toString("hex");
|
||||||
|
const body = JSON.stringify({
|
||||||
|
appid: params.appId,
|
||||||
|
mchid: params.mchId,
|
||||||
|
description: params.description,
|
||||||
|
out_trade_no: params.outTradeNo,
|
||||||
|
notify_url: params.notifyUrl,
|
||||||
|
amount: {
|
||||||
|
total: params.amountFen,
|
||||||
|
currency: "CNY",
|
||||||
|
},
|
||||||
|
attach: params.attach,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = buildWechatPaySignature({
|
||||||
|
method: "POST",
|
||||||
|
path,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
body,
|
||||||
|
privateKey: params.privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorization = buildWechatAuthorization({
|
||||||
|
mchId: params.mchId,
|
||||||
|
serialNo: params.serialNo,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${WECHAT_PAY_API_BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new WechatPayError(
|
||||||
|
typeof data?.message === "string" ? data.message : "Failed to create WeChat native order",
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.code_url) {
|
||||||
|
throw new WechatPayError("WeChat order response missing code_url", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as WechatNativeOrderResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWechatPaySignature({
|
||||||
|
body,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
signature,
|
||||||
|
platformPublicKey,
|
||||||
|
}: {
|
||||||
|
body: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
signature: string;
|
||||||
|
platformPublicKey: string;
|
||||||
|
}) {
|
||||||
|
const message = `${timestamp}\n${nonce}\n${body}\n`;
|
||||||
|
const verifier = createVerify("RSA-SHA256");
|
||||||
|
verifier.update(message);
|
||||||
|
verifier.end();
|
||||||
|
return verifier.verify(normalizePem(platformPublicKey), signature, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptWechatPayResource({
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
associatedData,
|
||||||
|
apiV3Key,
|
||||||
|
}: {
|
||||||
|
ciphertext: string;
|
||||||
|
nonce: string;
|
||||||
|
associatedData?: string;
|
||||||
|
apiV3Key: string;
|
||||||
|
}) {
|
||||||
|
const key = Buffer.from(apiV3Key, "utf8");
|
||||||
|
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new WechatPayError("WECHAT_PAY_API_V3_KEY must be 32 bytes", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(ciphertext, "base64");
|
||||||
|
const authTag = data.subarray(data.length - 16);
|
||||||
|
const encryptedData = data.subarray(0, data.length - 16);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "utf8"));
|
||||||
|
if (associatedData) {
|
||||||
|
decipher.setAAD(Buffer.from(associatedData, "utf8"));
|
||||||
|
}
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
const plaintext = Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString("utf8");
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWechatNotifyPayload({
|
||||||
|
body,
|
||||||
|
apiV3Key,
|
||||||
|
}: {
|
||||||
|
body: string;
|
||||||
|
apiV3Key: string;
|
||||||
|
}) {
|
||||||
|
const envelope = JSON.parse(body) as WechatPayNotifyEnvelope;
|
||||||
|
const resource = envelope.resource;
|
||||||
|
|
||||||
|
const plaintext = decryptWechatPayResource({
|
||||||
|
ciphertext: resource.ciphertext,
|
||||||
|
nonce: resource.nonce,
|
||||||
|
associatedData: resource.associated_data,
|
||||||
|
apiV3Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelope,
|
||||||
|
transaction: JSON.parse(plaintext) as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -772,10 +772,11 @@ export async function getAllSystemSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type StripeWebhookEventRecord = {
|
export type BillingTimelineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
channel: "stripe" | "wechat";
|
||||||
type: string;
|
type: string;
|
||||||
status: "processed" | "failed";
|
status: string;
|
||||||
planId?: string;
|
planId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@ -811,8 +812,9 @@ export async function getStripeWebhookEventsByUser({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value = row.value as Record<string, unknown>;
|
const value = row.value as Record<string, unknown>;
|
||||||
const record: StripeWebhookEventRecord = {
|
const record: BillingTimelineItem = {
|
||||||
id: typeof value.id === "string" ? value.id : row.key.replace("stripe:webhook:event:", ""),
|
id: typeof value.id === "string" ? value.id : row.key.replace("stripe:webhook:event:", ""),
|
||||||
|
channel: "stripe",
|
||||||
type: typeof value.type === "string" ? value.type : "unknown",
|
type: typeof value.type === "string" ? value.type : "unknown",
|
||||||
status: value.status === "failed" ? "failed" : "processed",
|
status: value.status === "failed" ? "failed" : "processed",
|
||||||
planId: typeof value.planId === "string" ? value.planId : undefined,
|
planId: typeof value.planId === "string" ? value.planId : undefined,
|
||||||
@ -827,7 +829,7 @@ export async function getStripeWebhookEventsByUser({
|
|||||||
|
|
||||||
return record;
|
return record;
|
||||||
})
|
})
|
||||||
.filter((item): item is StripeWebhookEventRecord => {
|
.filter((item): item is BillingTimelineItem => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -850,6 +852,104 @@ export async function getStripeWebhookEventsByUser({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWechatPayOrdersByUser({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
limit = 20,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
email?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
key: systemSettings.key,
|
||||||
|
value: systemSettings.value,
|
||||||
|
updatedAt: systemSettings.updatedAt,
|
||||||
|
})
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(like(systemSettings.key, "wechat:pay:order:%"))
|
||||||
|
.orderBy(desc(systemSettings.updatedAt))
|
||||||
|
.limit(limit * 4);
|
||||||
|
|
||||||
|
const events = rows
|
||||||
|
.map((row) => {
|
||||||
|
if (!row.value || typeof row.value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = row.value as Record<string, unknown>;
|
||||||
|
const item: BillingTimelineItem = {
|
||||||
|
id:
|
||||||
|
typeof value.outTradeNo === "string"
|
||||||
|
? value.outTradeNo
|
||||||
|
: row.key.replace("wechat:pay:order:", ""),
|
||||||
|
channel: "wechat",
|
||||||
|
type: "wechat.order",
|
||||||
|
status: typeof value.status === "string" ? value.status : "unknown",
|
||||||
|
planId: typeof value.planId === "string" ? value.planId : undefined,
|
||||||
|
userId: typeof value.userId === "string" ? value.userId : undefined,
|
||||||
|
email: typeof value.email === "string" ? value.email : undefined,
|
||||||
|
occurredAt:
|
||||||
|
(typeof value.paidAt === "string" && value.paidAt) ||
|
||||||
|
(typeof value.createdAt === "string" && value.createdAt) ||
|
||||||
|
row.updatedAt?.toISOString() ||
|
||||||
|
new Date().toISOString(),
|
||||||
|
detail:
|
||||||
|
(typeof value.detail === "string" && value.detail) ||
|
||||||
|
(typeof value.transactionId === "string" && value.transactionId) ||
|
||||||
|
undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.filter((item): item is BillingTimelineItem => {
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.userId === userId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email && item.email && item.email.toLowerCase() === email.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBillingTimelineByUser({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
limit = 20,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
email?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const [stripeEvents, wechatEvents] = await Promise.all([
|
||||||
|
getStripeWebhookEventsByUser({ userId, email, limit }),
|
||||||
|
getWechatPayOrdersByUser({ userId, email, limit }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [...stripeEvents, ...wechatEvents]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTs = new Date(a.occurredAt).getTime();
|
||||||
|
const bTs = new Date(b.occurredAt).getTime();
|
||||||
|
return bTs - aTs;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
export async function upsertSystemSetting({
|
export async function upsertSystemSetting({
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
|
|||||||
6
proxy.ts
6
proxy.ts
@ -13,6 +13,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
pathname === "/lovart/news";
|
pathname === "/lovart/news";
|
||||||
const isPublicMarketingApi = pathname === "/api/public/lovart" || pathname.startsWith("/api/public/lovart/");
|
const isPublicMarketingApi = pathname === "/api/public/lovart" || pathname.startsWith("/api/public/lovart/");
|
||||||
const isStripeWebhookRoute = pathname === "/api/billing/webhook/stripe";
|
const isStripeWebhookRoute = pathname === "/api/billing/webhook/stripe";
|
||||||
|
const isWechatWebhookRoute = pathname === "/api/billing/wechat/notify";
|
||||||
|
|
||||||
// 🛡️ 安全检查1:阻止黑名单IP
|
// 🛡️ 安全检查1:阻止黑名单IP
|
||||||
const clientIP = getClientIP(request);
|
const clientIP = getClientIP(request);
|
||||||
@ -35,7 +36,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
|
|
||||||
// 允许 Stripe Webhook 回调(第三方服务端回调)
|
// 允许 Stripe Webhook 回调(第三方服务端回调)
|
||||||
// 放在速率限制之前,避免第三方回调被误拦截
|
// 放在速率限制之前,避免第三方回调被误拦截
|
||||||
if (isStripeWebhookRoute) {
|
if (isStripeWebhookRoute || isWechatWebhookRoute) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +113,8 @@ export async function proxy(request: NextRequest) {
|
|||||||
!["/login", "/register"].includes(pathname) &&
|
!["/login", "/register"].includes(pathname) &&
|
||||||
!isPublicMarketingRoute &&
|
!isPublicMarketingRoute &&
|
||||||
!isPublicMarketingApi &&
|
!isPublicMarketingApi &&
|
||||||
!isStripeWebhookRoute
|
!isStripeWebhookRoute &&
|
||||||
|
!isWechatWebhookRoute
|
||||||
) {
|
) {
|
||||||
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
|
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
|
||||||
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user