feat: add billing webhook idempotency and event timeline
This commit is contained in:
parent
353c3d04ff
commit
7bcab25a7c
@ -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 });
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -89,5 +89,9 @@
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- 已增加 Webhook 幂等保护:
|
||||
- 通过 `SystemSettings` 的 `stripe:webhook:event:*` 唯一键防重复处理。
|
||||
- 已增加订阅事件追踪:
|
||||
- `/billing` 页面展示当前用户最近订阅事件(基于 webhook 处理记录)。
|
||||
- 订阅权益映射:
|
||||
- 基于套餐写回 `User.monthlyImageLimit`。
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user