feat: wire lovart pricing checkout and stripe webhook sync
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 12:43:27 +08:00
parent f6b7758ec9
commit 7432cfc890
9 changed files with 623 additions and 14 deletions

View File

@ -38,3 +38,15 @@ GTP5_API_KEY=your-gtp5-api-key
# 模型名称(例如:gpt-5-nano)
GTP5_MODEL=gpt-5-nano
# Stripe 订阅支付配置
# 后端服务端密钥(sk_live / sk_test)
STRIPE_SECRET_KEY=sk_test_xxx
# Stripe Webhook 签名密钥(whsec_...)
STRIPE_WEBHOOK_SECRET=whsec_xxx
# 不同套餐对应的 Stripe Price ID
STRIPE_PRICE_ID_BASIC=price_xxx_basic
STRIPE_PRICE_ID_PRO=price_xxx_pro
STRIPE_PRICE_ID_ULTIMATE=price_xxx_ultimate

View File

@ -0,0 +1,73 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getStripePriceIdForPlan, isPaidBillingPlanId, type PaidBillingPlanId } from "@/lib/billing/plans";
import { createStripeCheckoutSession, StripeRequestError } from "@/lib/billing/stripe";
const checkoutSchema = z.object({
planId: z.string(),
});
function getExternalBase(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 = checkoutSchema.safeParse(await request.json());
if (!parsed.success || !isPaidBillingPlanId(parsed.data.planId)) {
return NextResponse.json({ error: "Invalid plan ID" }, { status: 400 });
}
const planId = parsed.data.planId as PaidBillingPlanId;
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const priceId = getStripePriceIdForPlan(planId);
if (!stripeSecretKey || !priceId) {
return NextResponse.json(
{ error: "Billing is not configured. Missing Stripe keys or plan price ID." },
{ status: 503 }
);
}
const baseUrl = getExternalBase(request);
const successUrl = new URL(`/lovart/pricing?checkout=success&plan=${planId}`, baseUrl).toString();
const cancelUrl = new URL(`/lovart/pricing?checkout=cancelled&plan=${planId}`, baseUrl).toString();
const checkoutSession = await createStripeCheckoutSession({
secretKey: stripeSecretKey,
priceId,
userId: session.user.id,
email: session.user.email,
planId,
successUrl,
cancelUrl,
});
return NextResponse.json({
id: checkoutSession.id,
url: checkoutSession.url,
});
} catch (error) {
if (error instanceof StripeRequestError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error("[Billing Checkout API] Error:", error);
return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 });
}
}

View File

@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import {
getMonthlyImageLimitByPlanId,
isBillingPlanId,
type BillingPlanId,
} from "@/lib/billing/plans";
import { verifyStripeSignature } from "@/lib/billing/stripe";
import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
type StripeEvent = {
id: string;
type: string;
data: {
object: {
metadata?: Record<string, string>;
customer_details?: {
email?: string;
};
mode?: string;
status?: string;
cancel_at_period_end?: boolean;
};
};
};
async function applyPlanToUser({
userId,
email,
planId,
}: {
userId?: string;
email?: string;
planId: BillingPlanId;
}) {
const monthlyImageLimit = getMonthlyImageLimitByPlanId(planId);
const updateData = {
monthlyImageLimit,
updatedAt: new Date(),
};
if (userId) {
await db.update(user).set(updateData).where(eq(user.id, userId));
return;
}
if (email) {
await db
.update(user)
.set(updateData)
.where(and(eq(user.email, email), eq(user.isActive, true)));
}
}
function extractUserRef(object: StripeEvent["data"]["object"]) {
return {
userId: object.metadata?.userId,
email: object.customer_details?.email,
};
}
export async function POST(request: Request) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: "Webhook secret is not configured" }, { status: 503 });
}
const rawPayload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
}
const verified = verifyStripeSignature({
payload: rawPayload,
signatureHeader: signature,
webhookSecret,
});
if (!verified) {
return NextResponse.json({ error: "Invalid webhook signature" }, { status: 400 });
}
try {
const event = JSON.parse(rawPayload) as StripeEvent;
const object = event.data.object;
const userRef = extractUserRef(object);
if (event.type === "checkout.session.completed") {
if (object.mode !== "subscription") {
return NextResponse.json({ received: true });
}
const planId = object.metadata?.planId;
if (planId && isBillingPlanId(planId)) {
await applyPlanToUser({ ...userRef, planId });
}
return NextResponse.json({ received: true });
}
if (event.type === "customer.subscription.updated") {
const planId = object.metadata?.planId;
if (planId && isBillingPlanId(planId) && object.status === "active") {
await applyPlanToUser({ ...userRef, planId });
}
return NextResponse.json({ received: true });
}
if (event.type === "customer.subscription.deleted") {
await applyPlanToUser({ ...userRef, planId: "starter" });
return NextResponse.json({ received: true });
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("[Billing Stripe Webhook] Error:", error);
return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 });
}
}

View File

@ -1,6 +1,8 @@
import Link from "next/link";
import { Check, Rocket, ShieldCheck } from "lucide-react";
import { getCheckoutEnabledPlans } from "@/lib/billing/plans";
import { MarketingShell } from "@/components/lovart/marketing-shell";
import { PricingActionButton } from "@/components/lovart/pricing-action-button";
import { Reveal } from "@/components/lovart/reveal";
import {
getEnabledAiModelsWithProvider,
@ -10,7 +12,25 @@ import {
getTutorialVideos,
} from "@/lib/db/queries";
export default async function LovartPricingPage() {
type PricingPageProps = {
searchParams?: Promise<{ checkout?: string; plan?: string }> | { checkout?: string; plan?: string };
};
type PlanViewModel = {
id: "starter" | "basic" | "pro" | "ultimate";
name: string;
price: string;
period: string;
desc: string;
features: string[];
highlight?: boolean;
};
export default async function LovartPricingPage({ searchParams }: PricingPageProps) {
const resolvedSearchParams = await Promise.resolve(searchParams);
const checkoutState = resolvedSearchParams?.checkout;
const selectedPlan = resolvedSearchParams?.plan;
const [settings, navLinks, models, videos, featuredImages] = await Promise.all([
getSiteSettings(),
getNavLinks(),
@ -21,9 +41,11 @@ export default async function LovartPricingPage() {
const modelCount = models.length;
const tutorialCount = videos.length;
const checkoutEnabledPlans = getCheckoutEnabledPlans();
const plans = [
const plans: PlanViewModel[] = [
{
id: "starter",
name: "Starter",
price: "¥0",
period: "/月",
@ -35,6 +57,7 @@ export default async function LovartPricingPage() {
],
},
{
id: "basic",
name: "Basic",
price: "¥69",
period: "/月",
@ -42,6 +65,7 @@ export default async function LovartPricingPage() {
features: ["高清导出", "多风格模板", "历史记录与作品管理"],
},
{
id: "pro",
name: "Pro",
price: "¥199",
period: "/月",
@ -54,6 +78,7 @@ export default async function LovartPricingPage() {
highlight: true,
},
{
id: "ultimate",
name: "Ultimate",
price: "¥599",
period: "/月",
@ -103,6 +128,20 @@ export default async function LovartPricingPage() {
</section>
</Reveal>
{checkoutState ? (
<Reveal delay={0.03}>
<section className="mt-4 rounded-2xl border border-white/15 bg-white/[0.03] p-4">
{checkoutState === "success" ? (
<p className="text-sm text-emerald-200">
支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新。
</p>
) : (
<p className="text-sm text-amber-200">支付已取消,你可以随时重新发起订阅。</p>
)}
</section>
</Reveal>
) : null}
<Reveal delay={0.06}>
<section className="mt-8 grid gap-4 xl:grid-cols-4">
{plans.map((plan) => (
@ -130,16 +169,25 @@ export default async function LovartPricingPage() {
))}
</ul>
<Link
href="/register"
className={`mt-6 inline-flex w-full items-center justify-center rounded-xl px-4 py-2 text-sm font-semibold ${
plan.highlight
? "bg-white text-zinc-900 hover:bg-zinc-200"
: "border border-white/20 text-zinc-100 hover:border-white/40"
}`}
>
选择 {plan.name}
</Link>
{plan.id === "starter" ? (
<Link
href="/register"
className={`mt-6 inline-flex w-full items-center justify-center rounded-xl px-4 py-2 text-sm font-semibold ${
plan.highlight
? "bg-white text-zinc-900 hover:bg-zinc-200"
: "border border-white/20 text-zinc-100 hover:border-white/40"
}`}
>
选择 {plan.name}
</Link>
) : (
<PricingActionButton
checkoutEnabled={checkoutEnabledPlans[plan.id]}
highlight={plan.highlight}
planId={plan.id}
planName={plan.name}
/>
)}
</article>
))}
</section>

View File

@ -0,0 +1,93 @@
"use client";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { cn } from "@/lib/utils";
export function PricingActionButton({
planId,
planName,
checkoutEnabled,
highlight,
}: {
planId: string;
planName: string;
checkoutEnabled: boolean;
highlight?: boolean;
}) {
const { data: session, status } = useSession();
const router = useRouter();
const [isPending, setIsPending] = useState(false);
async function handleCheckout() {
if (isPending) {
return;
}
if (status === "loading") {
return;
}
if (!checkoutEnabled) {
window.alert("支付尚未配置,请先在环境变量中配置 Stripe Price ID。");
return;
}
if (!session?.user || session.user.type === "guest") {
router.push("/login?callbackUrl=/lovart/pricing");
return;
}
setIsPending(true);
try {
const response = await fetch("/api/billing/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ planId }),
});
const data = (await response.json()) as { error?: string; url?: string };
if (!response.ok || !data.url) {
window.alert(data.error || "创建支付会话失败,请稍后再试。");
return;
}
window.location.assign(data.url);
} catch (error) {
console.error("[PricingActionButton] checkout error", error);
window.alert("创建支付会话失败,请稍后再试。");
} finally {
setIsPending(false);
}
}
return (
<button
className={cn(
"mt-6 inline-flex w-full items-center justify-center rounded-xl px-4 py-2 text-sm font-semibold",
highlight
? "bg-white text-zinc-900 hover:bg-zinc-200"
: "border border-white/20 text-zinc-100 hover:border-white/40",
isPending && "cursor-not-allowed opacity-75"
)}
disabled={isPending}
onClick={handleCheckout}
type="button"
>
{isPending ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
跳转支付中...
</span>
) : (
`订阅 ${planName}`
)}
</button>
);
}

View File

@ -22,6 +22,8 @@
| `/api/public/lovart?view=news` | 新闻页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=blog` | 博客页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=changelog` | 更新日志聚合数据 | Public API | 已接入 |
| `POST /api/billing/checkout` | Stripe Checkout 会话创建 | 登录用户触发支付 | 已接入 |
| `POST /api/billing/webhook/stripe` | Stripe 事件回调 | 更新用户套餐额度 | 已接入 |
## 新增查询与 API
@ -30,9 +32,17 @@
- `getPublicFeaturedRanking(limit)`
- `getPublicChangelogEvents(limit)`
- `getTutorialVideos()`(改为静态导入,减少运行时开销)
- 支付配置(`lib/billing/plans.ts`)
- 套餐定义、额度映射、Stripe Price ID 读取
- Stripe 工具(`lib/billing/stripe.ts`)
- Checkout Session 创建
- Webhook 签名校验
- 公开接口(`app/api/public/lovart/route.ts`)
- `GET /api/public/lovart?view=home|pricing|news|blog|changelog`
- 返回公共营销页所需聚合数据(含短缓存头)
- 计费接口
- `POST /api/billing/checkout`
- `POST /api/billing/webhook/stripe`
## 页面层联动点
@ -50,7 +60,7 @@
| 项目 | 说明 | 后续动作 |
|---|---|---|
| 转化事件埋点 | 参考站有较重埋点(GTM/像素) | 接入统一埋点层,避免散落脚本 |
| 支付与订阅 | 当前仅展示定价 UI | 接 Stripe/微信支付并写回用户权益 |
| 支付与订阅管理页 | 已接入 Stripe Checkout 与 Webhook | 增加用户中心“管理订阅/账单”入口 |
| 多语言内容 | 参考站多语言切换 | 增加 i18n 路由和文案资源 |
| 复杂交互动效 | 目前是首版时序动效 | 按参考页面做逐区块微交互调优 |
| 页面 AB 参数化 | 参考站含实验参数 | 结合后台配置做模块开关 |
@ -60,3 +70,20 @@
- 当前公开页直接走服务端查询,性能可控;高并发建议在查询层增加缓存。
- 新增 Public API 仅输出营销页公共数据,不含私密用户字段。
- 若后续改成纯客户端渲染,可直接消费 `/api/public/lovart`,减少页面层重复聚合逻辑。
## Stripe 接入说明(当前版本)
- 需要在环境变量中配置:
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_PRICE_ID_BASIC`
- `STRIPE_PRICE_ID_PRO`
- `STRIPE_PRICE_ID_ULTIMATE`
- Stripe Webhook 回调地址:
- `POST /api/billing/webhook/stripe`
- 当前事件处理:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- 订阅权益映射:
- 基于套餐写回 `User.monthlyImageLimit`。

86
lib/billing/plans.ts Normal file
View File

@ -0,0 +1,86 @@
export type BillingPlanId = "starter" | "basic" | "pro" | "ultimate";
export type PaidBillingPlanId = Exclude<BillingPlanId, "starter">;
export type BillingPlanDefinition = {
id: BillingPlanId;
name: string;
price: string;
period: string;
monthlyImageLimit: number | null;
};
export const BILLING_PLAN_DEFINITIONS: BillingPlanDefinition[] = [
{
id: "starter",
name: "Starter",
price: "¥0",
period: "/month",
monthlyImageLimit: 200,
},
{
id: "basic",
name: "Basic",
price: "¥69",
period: "/month",
monthlyImageLimit: 1200,
},
{
id: "pro",
name: "Pro",
price: "¥199",
period: "/month",
monthlyImageLimit: 6000,
},
{
id: "ultimate",
name: "Ultimate",
price: "¥599",
period: "/month",
monthlyImageLimit: null,
},
];
const PAID_PLAN_IDS: PaidBillingPlanId[] = ["basic", "pro", "ultimate"];
const STRIPE_PRICE_ENV: Record<PaidBillingPlanId, string> = {
basic: "STRIPE_PRICE_ID_BASIC",
pro: "STRIPE_PRICE_ID_PRO",
ultimate: "STRIPE_PRICE_ID_ULTIMATE",
};
export function isBillingPlanId(value: string): value is BillingPlanId {
return BILLING_PLAN_DEFINITIONS.some((plan) => plan.id === value);
}
export function isPaidBillingPlanId(value: string): value is PaidBillingPlanId {
return PAID_PLAN_IDS.includes(value as PaidBillingPlanId);
}
export function getBillingPlanById(planId: BillingPlanId) {
return BILLING_PLAN_DEFINITIONS.find((plan) => plan.id === planId) || null;
}
export function getPaidPlanById(planId: PaidBillingPlanId) {
return BILLING_PLAN_DEFINITIONS.find((plan) => plan.id === planId) || null;
}
export function getMonthlyImageLimitByPlanId(planId: BillingPlanId) {
return getBillingPlanById(planId)?.monthlyImageLimit ?? null;
}
export function getStripePriceIdForPlan(planId: PaidBillingPlanId) {
const envName = STRIPE_PRICE_ENV[planId];
const value = process.env[envName];
return value && value.trim().length > 0 ? value.trim() : null;
}
export function getCheckoutEnabledPlans() {
return PAID_PLAN_IDS.reduce<Record<PaidBillingPlanId, boolean>>((acc, planId) => {
acc[planId] = Boolean(getStripePriceIdForPlan(planId));
return acc;
}, {
basic: false,
pro: false,
ultimate: false,
});
}

136
lib/billing/stripe.ts Normal file
View File

@ -0,0 +1,136 @@
import { createHmac, timingSafeEqual } from "node:crypto";
const STRIPE_API_BASE = "https://api.stripe.com/v1";
type StripeCheckoutSession = {
id: string;
url: string;
mode: string;
metadata?: Record<string, string>;
};
type CheckoutSessionParams = {
secretKey: string;
priceId: string;
userId: string;
email: string;
planId: string;
successUrl: string;
cancelUrl: string;
};
export class StripeRequestError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
function buildCheckoutBody(params: CheckoutSessionParams) {
const body = new URLSearchParams();
body.set("mode", "subscription");
body.set("success_url", params.successUrl);
body.set("cancel_url", params.cancelUrl);
body.set("line_items[0][price]", params.priceId);
body.set("line_items[0][quantity]", "1");
body.set("allow_promotion_codes", "true");
body.set("client_reference_id", params.userId);
body.set("customer_email", params.email);
body.set("metadata[userId]", params.userId);
body.set("metadata[planId]", params.planId);
body.set("subscription_data[metadata][userId]", params.userId);
body.set("subscription_data[metadata][planId]", params.planId);
return body;
}
export async function createStripeCheckoutSession(params: CheckoutSessionParams) {
const response = await fetch(`${STRIPE_API_BASE}/checkout/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: buildCheckoutBody(params).toString(),
});
const data = await response.json();
if (!response.ok) {
const message =
typeof data?.error?.message === "string"
? data.error.message
: "Stripe checkout session creation failed";
throw new StripeRequestError(message, response.status);
}
if (!data?.url || !data?.id) {
throw new StripeRequestError("Stripe checkout response missing session URL", 502);
}
return data as StripeCheckoutSession;
}
export function verifyStripeSignature({
payload,
signatureHeader,
webhookSecret,
toleranceSeconds = 300,
}: {
payload: string;
signatureHeader: string;
webhookSecret: string;
toleranceSeconds?: number;
}) {
const parsed = signatureHeader.split(",").reduce<Record<string, string[]>>((acc, part) => {
const [key, value] = part.split("=");
if (!key || !value) {
return acc;
}
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(value);
return acc;
}, {});
const timestamp = parsed.t?.[0];
const signatures = parsed.v1 || [];
if (!timestamp || signatures.length === 0) {
return false;
}
const signedPayload = `${timestamp}.${payload}`;
const expected = createHmac("sha256", webhookSecret).update(signedPayload, "utf8").digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const valid = signatures.some((signature) => {
const candidateBuffer = Buffer.from(signature, "hex");
if (candidateBuffer.length !== expectedBuffer.length) {
return false;
}
return timingSafeEqual(candidateBuffer, expectedBuffer);
});
if (!valid) {
return false;
}
const now = Math.floor(Date.now() / 1000);
const eventTs = Number(timestamp);
if (!Number.isFinite(eventTs)) {
return false;
}
return Math.abs(now - eventTs) <= toleranceSeconds;
}

View File

@ -12,6 +12,7 @@ export async function proxy(request: NextRequest) {
pathname === "/lovart/pricing" ||
pathname === "/lovart/news";
const isPublicMarketingApi = pathname === "/api/public/lovart" || pathname.startsWith("/api/public/lovart/");
const isStripeWebhookRoute = pathname === "/api/billing/webhook/stripe";
// 🛡️ 安全检查1:阻止黑名单IP
const clientIP = getClientIP(request);
@ -32,6 +33,12 @@ export async function proxy(request: NextRequest) {
// 可以在这里添加更严格的检查
}
// 允许 Stripe Webhook 回调(第三方服务端回调)
// 放在速率限制之前,避免第三方回调被误拦截
if (isStripeWebhookRoute) {
return NextResponse.next();
}
// 🛡️ 安全检查3:速率限制
// 对API路径应用速率限制
if (pathname.startsWith("/api/")) {
@ -104,7 +111,8 @@ export async function proxy(request: NextRequest) {
!token &&
!["/login", "/register"].includes(pathname) &&
!isPublicMarketingRoute &&
!isPublicMarketingApi
!isPublicMarketingApi &&
!isStripeWebhookRoute
) {
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
if (pathname.startsWith("/api/") && request.method !== "GET") {