feat: add wechat pay channel to unified billing flow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-12 13:32:13 +08:00
parent 7bcab25a7c
commit f874b3464f
12 changed files with 1128 additions and 18 deletions

View File

@ -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

View 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 });
}
}

View 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);
}
}

View 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,
});
}

View File

@ -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>

View File

@ -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>

View 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>
</>
);
}

View File

@ -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`。

View File

@ -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
View 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>,
};
}

View File

@ -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,

View File

@ -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") {