From f874b3464f4ecf5fc83ed521d29c6b0378b87574 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 12 Feb 2026 13:32:13 +0800
Subject: [PATCH] feat: add wechat pay channel to unified billing flow
---
.env.example | 10 +
app/api/billing/wechat/checkout/route.ts | 107 ++++++
app/api/billing/wechat/notify/route.ts | 345 +++++++++++++++++++
app/api/billing/wechat/order-status/route.ts | 40 +++
app/billing/page.tsx | 20 +-
app/lovart/pricing/page.tsx | 21 +-
components/lovart/wechat-pricing-button.tsx | 202 +++++++++++
docs/lovart-重建接口联调清单.md | 17 +-
lib/billing/plans.ts | 41 +++
lib/billing/wechat.ts | 229 ++++++++++++
lib/db/queries.ts | 108 +++++-
proxy.ts | 6 +-
12 files changed, 1128 insertions(+), 18 deletions(-)
create mode 100644 app/api/billing/wechat/checkout/route.ts
create mode 100644 app/api/billing/wechat/notify/route.ts
create mode 100644 app/api/billing/wechat/order-status/route.ts
create mode 100644 components/lovart/wechat-pricing-button.tsx
create mode 100644 lib/billing/wechat.ts
diff --git a/.env.example b/.env.example
index 2d8c94d..717fa59 100644
--- a/.env.example
+++ b/.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_PRO=price_xxx_pro
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
diff --git a/app/api/billing/wechat/checkout/route.ts b/app/api/billing/wechat/checkout/route.ts
new file mode 100644
index 0000000..45e345a
--- /dev/null
+++ b/app/api/billing/wechat/checkout/route.ts
@@ -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 });
+ }
+}
diff --git a/app/api/billing/wechat/notify/route.ts b/app/api/billing/wechat/notify/route.ts
new file mode 100644
index 0000000..767eb37
--- /dev/null
+++ b/app/api/billing/wechat/notify/route.ts
@@ -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 | 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;
+}) {
+ 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 | 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;
+}) {
+ 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 | 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;
+ 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 | 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);
+ }
+}
diff --git a/app/api/billing/wechat/order-status/route.ts b/app/api/billing/wechat/order-status/route.ts
new file mode 100644
index 0000000..3e9be6e
--- /dev/null
+++ b/app/api/billing/wechat/order-status/route.ts
@@ -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;
+ 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,
+ });
+}
diff --git a/app/billing/page.tsx b/app/billing/page.tsx
index 25a96b0..5a1443c 100644
--- a/app/billing/page.tsx
+++ b/app/billing/page.tsx
@@ -5,9 +5,10 @@ import { auth } from "@/app/(auth)/auth";
import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button";
import {
getCheckoutEnabledPlans,
+ isWechatPayEnabled,
resolvePlanByMonthlyImageLimit,
} from "@/lib/billing/plans";
-import { getStripeWebhookEventsByUser, getUserById } from "@/lib/db/queries";
+import { getBillingTimelineByUser, getUserById } from "@/lib/db/queries";
function formatLimit(limit: number | null) {
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() {
const session = await auth();
@@ -58,13 +66,14 @@ export default async function BillingPage() {
redirect("/login?callbackUrl=/billing");
}
- const [billingEvents, checkoutEnabledPlans] = await Promise.all([
- getStripeWebhookEventsByUser({
+ const [billingEvents, checkoutEnabledPlans, wechatEnabled] = await Promise.all([
+ getBillingTimelineByUser({
userId: user.id,
email: user.email,
limit: 12,
}),
Promise.resolve(getCheckoutEnabledPlans()),
+ Promise.resolve(isWechatPayEnabled()),
]);
const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit);
@@ -117,6 +126,10 @@ export default async function BillingPage() {
已配置可下单套餐:{checkoutAvailableCount}/3
+
+
+ 微信支付通道:{wechatEnabled ? "已启用" : "未启用"}
+
订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。
@@ -148,6 +161,7 @@ export default async function BillingPage() {
+ {formatChannel(event.channel)} ·{" "}
{formatDateTime(event.occurredAt)}
{event.planId ? ` · ${event.planId}` : ""}
diff --git a/app/lovart/pricing/page.tsx b/app/lovart/pricing/page.tsx
index 9fffd03..be7423e 100644
--- a/app/lovart/pricing/page.tsx
+++ b/app/lovart/pricing/page.tsx
@@ -1,8 +1,9 @@
import Link from "next/link";
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 { PricingActionButton } from "@/components/lovart/pricing-action-button";
+import { WechatPricingButton } from "@/components/lovart/wechat-pricing-button";
import { Reveal } from "@/components/lovart/reveal";
import {
getEnabledAiModelsWithProvider,
@@ -42,6 +43,7 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
const modelCount = models.length;
const tutorialCount = videos.length;
const checkoutEnabledPlans = getCheckoutEnabledPlans();
+ const wechatEnabled = isWechatPayEnabled();
const plans: PlanViewModel[] = [
{
@@ -186,12 +188,15 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
选择 {plan.name}
) : (
-
+
)}
))}
@@ -213,7 +218,7 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
- 支持接入 Stripe / 微信支付,并保留当前视觉样式。
+ 已支持 Stripe + 微信支付双通道,并统一在订阅中心管理。
diff --git a/components/lovart/wechat-pricing-button.tsx b/components/lovart/wechat-pricing-button.tsx
new file mode 100644
index 0000000..94f1eb0
--- /dev/null
+++ b/components/lovart/wechat-pricing-button.tsx
@@ -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(null);
+ const [qrCodeImageUrl, setQrCodeImageUrl] = useState(null);
+ const [codeUrl, setCodeUrl] = useState(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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/docs/lovart-重建接口联调清单.md b/docs/lovart-重建接口联调清单.md
index 5f00590..e2736b7 100644
--- a/docs/lovart-重建接口联调清单.md
+++ b/docs/lovart-重建接口联调清单.md
@@ -25,6 +25,9 @@
| `POST /api/billing/checkout` | Stripe Checkout 会话创建 | 登录用户触发支付 | 已接入 |
| `POST /api/billing/webhook/stripe` | Stripe 事件回调 | 更新用户套餐额度 | 已接入 |
| `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` | 用户订阅中心页面 | 查看套餐与进入账单门户 | 已接入 |
## 新增查询与 API
@@ -46,6 +49,9 @@
- `POST /api/billing/checkout`
- `POST /api/billing/webhook/stripe`
- `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_PRO`
- `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 回调地址:
- `POST /api/billing/webhook/stripe`
- 当前事件处理:
@@ -91,7 +104,9 @@
- `customer.subscription.deleted`
- 已增加 Webhook 幂等保护:
- 通过 `SystemSettings` 的 `stripe:webhook:event:*` 唯一键防重复处理。
+- 微信支付同样增加幂等保护:
+ - 使用 `SystemSettings` 的 `wechat:pay:event:*` 和 `wechat:pay:order:*` 追踪并防重。
- 已增加订阅事件追踪:
- - `/billing` 页面展示当前用户最近订阅事件(基于 webhook 处理记录)。
+ - `/billing` 页面展示 Stripe + 微信支付统一时间线。
- 订阅权益映射:
- 基于套餐写回 `User.monthlyImageLimit`。
diff --git a/lib/billing/plans.ts b/lib/billing/plans.ts
index e1f756d..7eeff98 100644
--- a/lib/billing/plans.ts
+++ b/lib/billing/plans.ts
@@ -7,6 +7,7 @@ export type BillingPlanDefinition = {
price: string;
period: string;
monthlyImageLimit: number | null;
+ cnyFen: number;
};
export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
@@ -16,6 +17,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
price: "¥0",
period: "/month",
monthlyImageLimit: 200,
+ cnyFen: 0,
},
{
id: "basic",
@@ -23,6 +25,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
price: "¥69",
period: "/month",
monthlyImageLimit: 1200,
+ cnyFen: 6900,
},
{
id: "pro",
@@ -30,6 +33,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
price: "¥199",
period: "/month",
monthlyImageLimit: 6000,
+ cnyFen: 19900,
},
{
id: "ultimate",
@@ -37,6 +41,7 @@ export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
price: "¥599",
period: "/month",
monthlyImageLimit: null,
+ cnyFen: 59900,
},
];
@@ -64,6 +69,10 @@ export function getPaidPlanById(planId: PaidBillingPlanId) {
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) {
return getBillingPlanById(planId)?.monthlyImageLimit ?? null;
}
@@ -113,3 +122,35 @@ export function getCheckoutEnabledPlans() {
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());
+}
diff --git a/lib/billing/wechat.ts b/lib/billing/wechat.ts
new file mode 100644
index 0000000..ad3bcfe
--- /dev/null
+++ b/lib/billing/wechat.ts
@@ -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,
+ };
+}
diff --git a/lib/db/queries.ts b/lib/db/queries.ts
index deb9652..5d533d9 100644
--- a/lib/db/queries.ts
+++ b/lib/db/queries.ts
@@ -772,10 +772,11 @@ export async function getAllSystemSettings() {
}
}
-type StripeWebhookEventRecord = {
+export type BillingTimelineItem = {
id: string;
+ channel: "stripe" | "wechat";
type: string;
- status: "processed" | "failed";
+ status: string;
planId?: string;
userId?: string;
email?: string;
@@ -811,8 +812,9 @@ export async function getStripeWebhookEventsByUser({
}
const value = row.value as Record;
- const record: StripeWebhookEventRecord = {
+ const record: BillingTimelineItem = {
id: typeof value.id === "string" ? value.id : row.key.replace("stripe:webhook:event:", ""),
+ channel: "stripe",
type: typeof value.type === "string" ? value.type : "unknown",
status: value.status === "failed" ? "failed" : "processed",
planId: typeof value.planId === "string" ? value.planId : undefined,
@@ -827,7 +829,7 @@ export async function getStripeWebhookEventsByUser({
return record;
})
- .filter((item): item is StripeWebhookEventRecord => {
+ .filter((item): item is BillingTimelineItem => {
if (!item) {
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;
+ 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({
key,
value,
diff --git a/proxy.ts b/proxy.ts
index 4c676d0..bcd0733 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -13,6 +13,7 @@ export async function proxy(request: NextRequest) {
pathname === "/lovart/news";
const isPublicMarketingApi = pathname === "/api/public/lovart" || pathname.startsWith("/api/public/lovart/");
const isStripeWebhookRoute = pathname === "/api/billing/webhook/stripe";
+ const isWechatWebhookRoute = pathname === "/api/billing/wechat/notify";
// 🛡️ 安全检查1:阻止黑名单IP
const clientIP = getClientIP(request);
@@ -35,7 +36,7 @@ export async function proxy(request: NextRequest) {
// 允许 Stripe Webhook 回调(第三方服务端回调)
// 放在速率限制之前,避免第三方回调被误拦截
- if (isStripeWebhookRoute) {
+ if (isStripeWebhookRoute || isWechatWebhookRoute) {
return NextResponse.next();
}
@@ -112,7 +113,8 @@ export async function proxy(request: NextRequest) {
!["/login", "/register"].includes(pathname) &&
!isPublicMarketingRoute &&
!isPublicMarketingApi &&
- !isStripeWebhookRoute
+ !isStripeWebhookRoute &&
+ !isWechatWebhookRoute
) {
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
if (pathname.startsWith("/api/") && request.method !== "GET") {