From 7bcab25a7c172fa636f76cb87225c910ae3870f0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 13:00:01 +0800 Subject: [PATCH] feat: add billing webhook idempotency and event timeline --- app/api/billing/webhook/stripe/route.ts | 193 +++++++++++++++++++++++- app/billing/page.tsx | 78 +++++++++- docs/lovart-重建接口联调清单.md | 4 + lib/db/queries.ts | 78 ++++++++++ 4 files changed, 349 insertions(+), 4 deletions(-) diff --git a/app/api/billing/webhook/stripe/route.ts b/app/api/billing/webhook/stripe/route.ts index e0f14d0..6e49e82 100644 --- a/app/api/billing/webhook/stripe/route.ts +++ b/app/api/billing/webhook/stripe/route.ts @@ -7,7 +7,7 @@ import { } from "@/lib/billing/plans"; import { verifyStripeSignature } from "@/lib/billing/stripe"; import { db } from "@/lib/db"; -import { user } from "@/lib/db/schema"; +import { systemSettings, user } from "@/lib/db/schema"; type StripeEvent = { id: string; @@ -18,6 +18,7 @@ type StripeEvent = { customer_details?: { email?: string; }; + customer?: string; mode?: string; status?: string; cancel_at_period_end?: boolean; @@ -60,6 +61,114 @@ function extractUserRef(object: StripeEvent["data"]["object"]) { }; } +function getEventSettingKey(eventId: string) { + return `stripe:webhook:event:${eventId}`; +} + +async function lockStripeWebhookEvent({ + eventId, + eventType, +}: { + eventId: string; + eventType: string; +}) { + const key = getEventSettingKey(eventId); + const now = new Date(); + + const [inserted] = await db + .insert(systemSettings) + .values({ + key, + value: { + id: eventId, + type: eventType, + 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 existingValue = existing?.value as Record | undefined; + const status = typeof existingValue?.status === "string" ? existingValue.status : "unknown"; + const attempts = typeof existingValue?.attempts === "number" ? existingValue.attempts : 1; + + if (status === "processed") { + return { key, shouldProcess: false as const }; + } + + await db + .update(systemSettings) + .set({ + value: { + ...(existingValue || {}), + id: eventId, + type: eventType, + status: "processing", + occurredAt: + typeof existingValue?.occurredAt === "string" + ? existingValue.occurredAt + : now.toISOString(), + attempts: attempts + 1, + }, + updatedAt: now, + updatedBy: null, + }) + .where(eq(systemSettings.key, key)); + + return { key, shouldProcess: true as const }; +} + +async function finalizeStripeWebhookEvent({ + key, + status, + payload, +}: { + key: string; + status: "processed" | "failed"; + payload: Record; +}) { + const now = new Date(); + const [existing] = await db + .select({ + value: systemSettings.value, + }) + .from(systemSettings) + .where(eq(systemSettings.key, key)) + .limit(1); + + const existingValue = existing?.value as Record | undefined; + + await db + .update(systemSettings) + .set({ + value: { + ...(existingValue || {}), + ...payload, + status, + updatedAt: now.toISOString(), + }, + updatedAt: now, + updatedBy: null, + }) + .where(eq(systemSettings.key, key)); +} + export async function POST(request: Request) { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -88,9 +197,28 @@ export async function POST(request: Request) { const event = JSON.parse(rawPayload) as StripeEvent; const object = event.data.object; const userRef = extractUserRef(object); + const eventLock = await lockStripeWebhookEvent({ + eventId: event.id, + eventType: event.type, + }); + + if (!eventLock.shouldProcess) { + return NextResponse.json({ received: true, duplicate: true }); + } if (event.type === "checkout.session.completed") { if (object.mode !== "subscription") { + await finalizeStripeWebhookEvent({ + key: eventLock.key, + status: "processed", + payload: { + id: event.id, + type: event.type, + detail: "ignored_non_subscription_checkout", + userId: userRef.userId, + email: userRef.email, + }, + }); return NextResponse.json({ received: true }); } @@ -100,6 +228,18 @@ export async function POST(request: Request) { await applyPlanToUser({ ...userRef, planId }); } + await finalizeStripeWebhookEvent({ + key: eventLock.key, + status: "processed", + payload: { + id: event.id, + type: event.type, + planId, + userId: userRef.userId, + email: userRef.email, + detail: "checkout_completed", + }, + }); return NextResponse.json({ received: true }); } @@ -110,16 +250,67 @@ export async function POST(request: Request) { await applyPlanToUser({ ...userRef, planId }); } + await finalizeStripeWebhookEvent({ + key: eventLock.key, + status: "processed", + payload: { + id: event.id, + type: event.type, + planId, + userId: userRef.userId, + email: userRef.email, + detail: `subscription_status:${object.status || "unknown"}`, + }, + }); return NextResponse.json({ received: true }); } if (event.type === "customer.subscription.deleted") { await applyPlanToUser({ ...userRef, planId: "starter" }); + await finalizeStripeWebhookEvent({ + key: eventLock.key, + status: "processed", + payload: { + id: event.id, + type: event.type, + planId: "starter", + userId: userRef.userId, + email: userRef.email, + detail: "subscription_deleted_to_starter", + }, + }); return NextResponse.json({ received: true }); } + await finalizeStripeWebhookEvent({ + key: eventLock.key, + status: "processed", + payload: { + id: event.id, + type: event.type, + userId: userRef.userId, + email: userRef.email, + detail: "ignored_event_type", + }, + }); return NextResponse.json({ received: true }); } catch (error) { + try { + const parsedEvent = JSON.parse(rawPayload) as StripeEvent; + const key = getEventSettingKey(parsedEvent.id); + await finalizeStripeWebhookEvent({ + key, + status: "failed", + payload: { + id: parsedEvent.id, + type: parsedEvent.type, + detail: error instanceof Error ? error.message : "unknown_webhook_error", + }, + }); + } catch (_internalError) { + // ignore parse/update failure + } + console.error("[Billing Stripe Webhook] Error:", error); return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 }); } diff --git a/app/billing/page.tsx b/app/billing/page.tsx index 22e0941..25a96b0 100644 --- a/app/billing/page.tsx +++ b/app/billing/page.tsx @@ -1,13 +1,13 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { CreditCard, ShieldCheck, Sparkles } from "lucide-react"; +import { CalendarClock, 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"; +import { getStripeWebhookEventsByUser, getUserById } from "@/lib/db/queries"; function formatLimit(limit: number | null) { if (limit == null || limit === 0) { @@ -17,6 +17,34 @@ function formatLimit(limit: number | null) { return `${limit.toLocaleString("zh-CN")} 张 / 月`; } +function formatDateTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function formatEventType(type: string) { + switch (type) { + case "checkout.session.completed": + return "支付完成"; + case "customer.subscription.updated": + return "订阅更新"; + case "customer.subscription.deleted": + return "订阅取消"; + default: + return type; + } +} + export default async function BillingPage() { const session = await auth(); @@ -30,8 +58,16 @@ export default async function BillingPage() { redirect("/login?callbackUrl=/billing"); } + const [billingEvents, checkoutEnabledPlans] = await Promise.all([ + getStripeWebhookEventsByUser({ + userId: user.id, + email: user.email, + limit: 12, + }), + Promise.resolve(getCheckoutEnabledPlans()), + ]); + const currentPlan = resolvePlanByMonthlyImageLimit(user.monthlyImageLimit); - const checkoutEnabledPlans = getCheckoutEnabledPlans(); const checkoutAvailableCount = Object.values(checkoutEnabledPlans).filter(Boolean).length; return ( @@ -88,6 +124,42 @@ export default async function BillingPage() { +
+
+ +

最近订阅事件

+
+ + {billingEvents.length > 0 ? ( +
+ {billingEvents.map((event) => ( +
+
+ {formatEventType(event.type)} + + {event.status === "processed" ? "已处理" : "失败"} + +
+

+ {formatDateTime(event.occurredAt)} + {event.planId ? ` · ${event.planId}` : ""} +

+ {event.detail ?

{event.detail}

: null} +
+ ))} +
+ ) : ( +

暂无订阅事件记录。

+ )} +
+

说明

    diff --git a/docs/lovart-重建接口联调清单.md b/docs/lovart-重建接口联调清单.md index 7402551..5f00590 100644 --- a/docs/lovart-重建接口联调清单.md +++ b/docs/lovart-重建接口联调清单.md @@ -89,5 +89,9 @@ - `checkout.session.completed` - `customer.subscription.updated` - `customer.subscription.deleted` +- 已增加 Webhook 幂等保护: + - 通过 `SystemSettings` 的 `stripe:webhook:event:*` 唯一键防重复处理。 +- 已增加订阅事件追踪: + - `/billing` 页面展示当前用户最近订阅事件(基于 webhook 处理记录)。 - 订阅权益映射: - 基于套餐写回 `User.monthlyImageLimit`。 diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 8d7c646..deb9652 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -772,6 +772,84 @@ export async function getAllSystemSettings() { } } +type StripeWebhookEventRecord = { + id: string; + type: string; + status: "processed" | "failed"; + planId?: string; + userId?: string; + email?: string; + occurredAt: string; + detail?: string; +}; + +export async function getStripeWebhookEventsByUser({ + 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, "stripe:webhook:event:%")) + .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; + const record: StripeWebhookEventRecord = { + id: typeof value.id === "string" ? value.id : row.key.replace("stripe:webhook:event:", ""), + type: typeof value.type === "string" ? value.type : "unknown", + status: value.status === "failed" ? "failed" : "processed", + 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.occurredAt === "string" + ? value.occurredAt + : row.updatedAt?.toISOString() || new Date().toISOString(), + detail: typeof value.detail === "string" ? value.detail : undefined, + }; + + return record; + }) + .filter((item): item is StripeWebhookEventRecord => { + 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 upsertSystemSetting({ key, value,