From 7432cfc8908da9d7ee990c7dfce3a403b67a8dfd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 12:43:27 +0800 Subject: [PATCH] feat: wire lovart pricing checkout and stripe webhook sync --- .env.example | 12 ++ app/api/billing/checkout/route.ts | 73 +++++++++++ app/api/billing/webhook/stripe/route.ts | 126 ++++++++++++++++++ app/lovart/pricing/page.tsx | 72 +++++++++-- components/lovart/pricing-action-button.tsx | 93 +++++++++++++ docs/lovart-重建接口联调清单.md | 29 ++++- lib/billing/plans.ts | 86 +++++++++++++ lib/billing/stripe.ts | 136 ++++++++++++++++++++ proxy.ts | 10 +- 9 files changed, 623 insertions(+), 14 deletions(-) create mode 100644 app/api/billing/checkout/route.ts create mode 100644 app/api/billing/webhook/stripe/route.ts create mode 100644 components/lovart/pricing-action-button.tsx create mode 100644 lib/billing/plans.ts create mode 100644 lib/billing/stripe.ts diff --git a/.env.example b/.env.example index 9a03f80..d205530 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..d29581f --- /dev/null +++ b/app/api/billing/checkout/route.ts @@ -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 }); + } +} diff --git a/app/api/billing/webhook/stripe/route.ts b/app/api/billing/webhook/stripe/route.ts new file mode 100644 index 0000000..e0f14d0 --- /dev/null +++ b/app/api/billing/webhook/stripe/route.ts @@ -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; + 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 }); + } +} diff --git a/app/lovart/pricing/page.tsx b/app/lovart/pricing/page.tsx index d7c8f89..0127504 100644 --- a/app/lovart/pricing/page.tsx +++ b/app/lovart/pricing/page.tsx @@ -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() { + {checkoutState ? ( + +
+ {checkoutState === "success" ? ( +

+ 支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新。 +

+ ) : ( +

支付已取消,你可以随时重新发起订阅。

+ )} +
+
+ ) : null} +
{plans.map((plan) => ( @@ -130,16 +169,25 @@ export default async function LovartPricingPage() { ))} - - 选择 {plan.name} - + {plan.id === "starter" ? ( + + 选择 {plan.name} + + ) : ( + + )} ))}
diff --git a/components/lovart/pricing-action-button.tsx b/components/lovart/pricing-action-button.tsx new file mode 100644 index 0000000..e4b9795 --- /dev/null +++ b/components/lovart/pricing-action-button.tsx @@ -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 ( + + ); +} diff --git a/docs/lovart-重建接口联调清单.md b/docs/lovart-重建接口联调清单.md index b66e11c..001a44a 100644 --- a/docs/lovart-重建接口联调清单.md +++ b/docs/lovart-重建接口联调清单.md @@ -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`。 diff --git a/lib/billing/plans.ts b/lib/billing/plans.ts new file mode 100644 index 0000000..4e6e2f5 --- /dev/null +++ b/lib/billing/plans.ts @@ -0,0 +1,86 @@ +export type BillingPlanId = "starter" | "basic" | "pro" | "ultimate"; +export type PaidBillingPlanId = Exclude; + +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 = { + 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>((acc, planId) => { + acc[planId] = Boolean(getStripePriceIdForPlan(planId)); + return acc; + }, { + basic: false, + pro: false, + ultimate: false, + }); +} diff --git a/lib/billing/stripe.ts b/lib/billing/stripe.ts new file mode 100644 index 0000000..2bf147c --- /dev/null +++ b/lib/billing/stripe.ts @@ -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; +}; + +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>((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; +} diff --git a/proxy.ts b/proxy.ts index 542a03a..4c676d0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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") {