feat: add billing center and stripe portal management
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:49:03 +08:00
parent 7432cfc890
commit 353c3d04ff
9 changed files with 369 additions and 1 deletions

View File

@ -46,6 +46,9 @@ STRIPE_SECRET_KEY=sk_test_xxx
# Stripe Webhook 签名密钥(whsec_...)
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Stripe Billing Portal 返回地址(可选)
STRIPE_BILLING_RETURN_URL=https://your-domain.com/billing
# 不同套餐对应的 Stripe Price ID
STRIPE_PRICE_ID_BASIC=price_xxx_basic
STRIPE_PRICE_ID_PRO=price_xxx_pro

View File

@ -0,0 +1,65 @@
import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createStripeBillingPortalSession,
findStripeCustomerIdByEmail,
StripeRequestError,
} from "@/lib/billing/stripe";
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 manage subscription" }, { status: 403 });
}
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
if (!stripeSecretKey) {
return NextResponse.json({ error: "Stripe is not configured" }, { status: 503 });
}
const customerId = await findStripeCustomerIdByEmail({
secretKey: stripeSecretKey,
email: session.user.email,
});
if (!customerId) {
return NextResponse.json({ error: "No Stripe subscription account found" }, { status: 404 });
}
const returnUrl = process.env.STRIPE_BILLING_RETURN_URL
? process.env.STRIPE_BILLING_RETURN_URL
: new URL("/billing", getBaseUrl(request)).toString();
const portalSession = await createStripeBillingPortalSession({
secretKey: stripeSecretKey,
customerId,
returnUrl,
});
return NextResponse.json({
id: portalSession.id,
url: portalSession.url,
});
} catch (error) {
if (error instanceof StripeRequestError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error("[Billing Portal API] Error:", error);
return NextResponse.json({ error: "Failed to create billing portal session" }, { status: 500 });
}
}

102
app/billing/page.tsx Normal file
View File

@ -0,0 +1,102 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { CreditCard, ShieldCheck, Sparkles } from "lucide-react";
import { auth } from "@/app/(auth)/auth";
import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button";
import {
getCheckoutEnabledPlans,
resolvePlanByMonthlyImageLimit,
} from "@/lib/billing/plans";
import { getUserById } from "@/lib/db/queries";
function formatLimit(limit: number | null) {
if (limit == null || limit === 0) {
return "无限制";
}
return `${limit.toLocaleString("zh-CN")} 张 / 月`;
}
export default async function BillingPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=/billing");
}
const user = await getUserById({ id: session.user.id });
if (!user) {
redirect("/login?callbackUrl=/billing");
}
const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit);
const checkoutEnabledPlans = getCheckoutEnabledPlans();
const checkoutAvailableCount = Object.values(checkoutEnabledPlans).filter(Boolean).length;
return (
<main className="min-h-dvh bg-[#050814] px-4 py-8 text-zinc-100 sm:px-6">
<div className="mx-auto w-full max-w-4xl space-y-6">
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-8">
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
<CreditCard className="h-3.5 w-3.5 text-cyan-300" />
Billing Center
</p>
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">订阅与账单管理</h1>
<p className="mt-3 text-sm text-zinc-300 sm:text-base">
查看当前套餐、权益额度,并可进入 Stripe 门户管理续费、取消和发票信息。
</p>
</section>
<section className="grid gap-4 md:grid-cols-[1.2fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="text-lg font-semibold text-white">当前订阅</h2>
<div className="mt-4 rounded-xl border border-cyan-300/30 bg-cyan-400/10 p-4">
<p className="text-xs uppercase tracking-wide text-cyan-100">Active Plan</p>
<p className="mt-1 text-2xl font-semibold text-white">{currentPlan.name}</p>
<p className="mt-2 text-sm text-zinc-200">
月度出图额度:<span className="font-medium text-white">{formatLimit(currentPlan.monthlyImageLimit)}</span>
</p>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/lovart/pricing"
className="inline-flex items-center justify-center rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
>
更换套餐
</Link>
{currentPlan.id !== "starter" ? <ManageSubscriptionButton /> : null}
</div>
</article>
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="text-lg font-semibold text-white">订阅状态</h2>
<div className="mt-3 space-y-2 text-sm text-zinc-300">
<p className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-cyan-300" />
当前账号:{session.user.name || session.user.email}
</p>
<p className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-cyan-300" />
已配置可下单套餐:{checkoutAvailableCount}/3
</p>
<p className="text-xs text-zinc-400">
订阅权益按 Webhook 自动写回账户额度(`monthlyImageLimit`)。
</p>
</div>
</article>
</section>
<section className="rounded-2xl border border-white/15 bg-black/25 p-5 text-sm text-zinc-300">
<h2 className="text-lg font-semibold text-white">说明</h2>
<ul className="mt-3 space-y-2">
<li>如已完成支付但权益未刷新,可稍后重试或联系管理员检查 Webhook 配置。</li>
<li>取消订阅后,到期会自动回退为 Starter 套餐额度。</li>
<li>账单、付款方式、发票下载统一在 Stripe Portal 中管理。</li>
</ul>
</section>
</div>
</main>
);
}

View File

@ -133,7 +133,12 @@ export default async function LovartPricingPage({ searchParams }: PricingPagePro
<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})` : ""}。如未生效,请稍后刷新。
支付已完成,正在同步你的订阅权益{selectedPlan ? `(${selectedPlan})` : ""}。如未生效,请稍后刷新,
或前往
<Link className="ml-1 underline hover:text-emerald-100" href="/billing">
订阅中心
</Link>
查看状态。
</p>
) : (
<p className="text-sm text-amber-200">支付已取消,你可以随时重新发起订阅。</p>

View File

@ -0,0 +1,63 @@
"use client";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
export function ManageSubscriptionButton({
className,
}: {
className?: string;
}) {
const [isPending, setIsPending] = useState(false);
async function handleOpenPortal() {
if (isPending) {
return;
}
setIsPending(true);
try {
const response = await fetch("/api/billing/portal", {
method: "POST",
});
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("[ManageSubscriptionButton] portal error", error);
window.alert("打开订阅管理失败,请稍后重试。");
} finally {
setIsPending(false);
}
}
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-xl border border-white/20 px-4 py-2 text-sm text-zinc-100 hover:border-white/40",
isPending && "cursor-not-allowed opacity-70",
className
)}
disabled={isPending}
onClick={handleOpenPortal}
type="button"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
正在跳转...
</>
) : (
"管理订阅"
)}
</button>
);
}

View File

@ -79,6 +79,17 @@ export function SidebarUserNav({ user }: { user: User }) {
>
切换到 {resolvedTheme === "light" ? "深色" : "浅色"} 模式
</DropdownMenuItem>
{!isGuest ? (
<DropdownMenuItem
className="cursor-pointer"
data-testid="user-nav-item-billing"
onSelect={() => {
router.push("/billing");
}}
>
订阅与账单
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button

View File

@ -24,6 +24,8 @@
| `/api/public/lovart?view=changelog` | 更新日志聚合数据 | Public API | 已接入 |
| `POST /api/billing/checkout` | Stripe Checkout 会话创建 | 登录用户触发支付 | 已接入 |
| `POST /api/billing/webhook/stripe` | Stripe 事件回调 | 更新用户套餐额度 | 已接入 |
| `POST /api/billing/portal` | Stripe Billing Portal 会话创建 | 登录用户管理订阅 | 已接入 |
| `/billing` | 用户订阅中心页面 | 查看套餐与进入账单门户 | 已接入 |
## 新增查询与 API
@ -43,6 +45,7 @@
- 计费接口
- `POST /api/billing/checkout`
- `POST /api/billing/webhook/stripe`
- `POST /api/billing/portal`
## 页面层联动点
@ -76,6 +79,7 @@
- 需要在环境变量中配置:
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_BILLING_RETURN_URL`(可选)
- `STRIPE_PRICE_ID_BASIC`
- `STRIPE_PRICE_ID_PRO`
- `STRIPE_PRICE_ID_ULTIMATE`

View File

@ -68,6 +68,35 @@ export function getMonthlyImageLimitByPlanId(planId: BillingPlanId) {
return getBillingPlanById(planId)?.monthlyImageLimit ?? null;
}
export function resolvePlanByMonthlyImageLimit(limit: number | null | undefined): BillingPlanDefinition {
const ultimatePlan =
BILLING_PLAN_DEFINITIONS.find((plan) => plan.id === "ultimate") || BILLING_PLAN_DEFINITIONS[0];
if (limit == null) {
return ultimatePlan;
}
const exactMatch = BILLING_PLAN_DEFINITIONS.find(
(plan) => plan.monthlyImageLimit != null && plan.monthlyImageLimit === limit
);
if (exactMatch) {
return exactMatch;
}
const sortedPlans = BILLING_PLAN_DEFINITIONS.filter((plan) => plan.monthlyImageLimit != null).sort(
(a, b) => (a.monthlyImageLimit ?? 0) - (b.monthlyImageLimit ?? 0)
);
for (const plan of sortedPlans) {
if ((plan.monthlyImageLimit ?? 0) >= limit) {
return plan;
}
}
return ultimatePlan;
}
export function getStripePriceIdForPlan(planId: PaidBillingPlanId) {
const envName = STRIPE_PRICE_ENV[planId];
const value = process.env[envName];

View File

@ -9,6 +9,18 @@ type StripeCheckoutSession = {
metadata?: Record<string, string>;
};
type StripeCustomerListResponse = {
data?: Array<{
id: string;
email?: string | null;
}>;
};
type StripePortalSession = {
id: string;
url: string;
};
type CheckoutSessionParams = {
secretKey: string;
priceId: string;
@ -76,6 +88,80 @@ export async function createStripeCheckoutSession(params: CheckoutSessionParams)
return data as StripeCheckoutSession;
}
export async function findStripeCustomerIdByEmail({
secretKey,
email,
}: {
secretKey: string;
email: string;
}) {
const url = new URL(`${STRIPE_API_BASE}/customers`);
url.searchParams.set("email", email);
url.searchParams.set("limit", "1");
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${secretKey}`,
},
});
const data = (await response.json()) as StripeCustomerListResponse & {
error?: { message?: string };
};
if (!response.ok) {
const message =
typeof data?.error?.message === "string"
? data.error.message
: "Failed to query Stripe customer";
throw new StripeRequestError(message, response.status);
}
return data.data?.[0]?.id ?? null;
}
export async function createStripeBillingPortalSession({
secretKey,
customerId,
returnUrl,
}: {
secretKey: string;
customerId: string;
returnUrl: string;
}) {
const body = new URLSearchParams();
body.set("customer", customerId);
body.set("return_url", returnUrl);
const response = await fetch(`${STRIPE_API_BASE}/billing_portal/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
const data = await response.json();
if (!response.ok) {
const message =
typeof data?.error?.message === "string"
? data.error.message
: "Stripe billing portal session creation failed";
throw new StripeRequestError(message, response.status);
}
if (!data?.url || !data?.id) {
throw new StripeRequestError("Stripe portal response missing URL", 502);
}
return data as StripePortalSession;
}
export function verifyStripeSignature({
payload,
signatureHeader,