feat: wire lovart pricing checkout and stripe webhook sync
This commit is contained in:
parent
f6b7758ec9
commit
7432cfc890
12
.env.example
12
.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
|
||||
|
||||
73
app/api/billing/checkout/route.ts
Normal file
73
app/api/billing/checkout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
126
app/api/billing/webhook/stripe/route.ts
Normal file
126
app/api/billing/webhook/stripe/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
93
components/lovart/pricing-action-button.tsx
Normal file
93
components/lovart/pricing-action-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
86
lib/billing/plans.ts
Normal 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
136
lib/billing/stripe.ts
Normal 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;
|
||||
}
|
||||
10
proxy.ts
10
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") {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user