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 ( + <> + + + + + + 微信扫码支付 + {statusText} + + +
    + {qrCodeImageUrl ? ( +
    + wechat pay qrcode +
    + ) : ( +
    + 正在生成二维码... +
    + )} + +
    +

    订单号:{outTradeNo || "-"}

    +
    + + +
    +
    +
    + + ); +} 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") {