feat: add billing webhook idempotency and event timeline
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 13:00:01 +08:00
parent 353c3d04ff
commit 7bcab25a7c
4 changed files with 349 additions and 4 deletions

View File

@ -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<string, unknown> | 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<string, unknown>;
}) {
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<string, unknown> | 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 });
}

View File

@ -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() {
</article>
</section>
<section className="rounded-2xl border border-white/15 bg-black/30 p-5">
<div className="mb-3 flex items-center gap-2">
<CalendarClock className="h-4 w-4 text-cyan-300" />
<h2 className="text-lg font-semibold text-white">最近订阅事件</h2>
</div>
{billingEvents.length > 0 ? (
<div className="space-y-2">
{billingEvents.map((event) => (
<div
key={event.id}
className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2"
>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-zinc-200">{formatEventType(event.type)}</span>
<span
className={
event.status === "processed" ? "text-emerald-200" : "text-amber-200"
}
>
{event.status === "processed" ? "已处理" : "失败"}
</span>
</div>
<p className="mt-1 text-[11px] text-zinc-400">
{formatDateTime(event.occurredAt)}
{event.planId ? ` · ${event.planId}` : ""}
</p>
{event.detail ? <p className="mt-1 text-xs text-zinc-400">{event.detail}</p> : null}
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-300">暂无订阅事件记录。</p>
)}
</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">

View File

@ -89,5 +89,9 @@
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- 已增加 Webhook 幂等保护:
- 通过 `SystemSettings` 的 `stripe:webhook:event:*` 唯一键防重复处理。
- 已增加订阅事件追踪:
- `/billing` 页面展示当前用户最近订阅事件(基于 webhook 处理记录)。
- 订阅权益映射:
- 基于套餐写回 `User.monthlyImageLimit`。

View File

@ -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<string, unknown>;
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,