From 16573c67c321df00472f90ff32f6cf912bd8e1e0 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Wed, 29 Apr 2026 16:38:31 +1000 Subject: [PATCH] fix: prevent duplicate support ticket submissions --- prisma/schema.prisma | 1 + src/actions/admin/settings.ts | 4 + src/actions/user/support.ts | 64 ++++--- .../admin/announcements/announcement-form.tsx | 9 +- src/app/(admin)/admin/commerce/page.tsx | 6 +- src/app/(admin)/admin/nodes/node-form.tsx | 5 +- .../(admin)/admin/services/service-form.tsx | 5 +- src/app/(admin)/admin/settings/page.tsx | 1 + .../(admin)/admin/settings/settings-form.tsx | 29 +++- .../_components/admin-support-reply-form.tsx | 4 +- .../_components/admin-support-table.tsx | 11 +- .../_components/support-ticket-meta-form.tsx | 4 +- .../tasks/_components/task-launch-panel.tsx | 4 +- .../tasks/_components/task-runs-table.tsx | 4 +- src/app/(admin)/admin/users/user-form.tsx | 5 +- .../create-support-ticket-form.tsx | 156 ++++++++++++------ .../_components/support-ticket-reply-form.tsx | 4 +- .../_components/user-support-ticket-table.tsx | 11 +- src/app/(user)/support/page.tsx | 14 +- src/app/(user)/support/support-data.ts | 9 + src/components/admin/batch-action-bar.tsx | 24 +-- .../admin/batch-action-button-client.tsx | 40 +++++ .../shared/confirm-action-button.tsx | 7 +- .../shared/pending-submit-button.tsx | 26 +++ 24 files changed, 327 insertions(+), 120 deletions(-) create mode 100644 src/components/admin/batch-action-button-client.tsx create mode 100644 src/components/shared/pending-submit-button.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bf1968..b9feb26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -756,6 +756,7 @@ model AppConfig { emailVerificationRequired Boolean @default(false) requireInviteCode Boolean @default(false) supportContact String? + supportOpenTicketLimit Int @default(2) maintenanceNotice String? siteNotice String? autoReminderDispatchEnabled Boolean @default(true) diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 42d1adc..950beb6 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -19,6 +19,7 @@ const settingsSchema = z.object({ supportContact: z.string().trim().optional(), maintenanceNotice: z.string().trim().optional(), siteNotice: z.string().trim().optional(), + supportOpenTicketLimit: z.coerce.number().int().min(1).max(20).optional(), allowRegistration: z.string().optional(), emailVerificationRequired: z.string().optional(), requireInviteCode: z.string().optional(), @@ -98,6 +99,7 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw siteUrl: normalizeSiteUrl(parsed.siteUrl) || null, subscriptionUrl: normalizeSiteUrl(parsed.subscriptionUrl) || null, supportContact: parsed.supportContact || null, + supportOpenTicketLimit: parsed.supportOpenTicketLimit ?? current.supportOpenTicketLimit, maintenanceNotice: parsed.maintenanceNotice || null, siteNotice: parsed.siteNotice || null, allowRegistration: parsed.allowRegistration === "true", @@ -174,6 +176,8 @@ function revalidateSettingsViews() { revalidatePath("/subscriptions"); revalidatePath("/admin/nodes"); revalidatePath("/account"); + revalidatePath("/support"); + revalidatePath("/admin/support"); revalidatePath("/admin/commerce"); revalidatePath("/admin/subscription-risk"); revalidatePath("/admin/subscriptions"); diff --git a/src/actions/user/support.ts b/src/actions/user/support.ts index 8c93a63..4727f97 100644 --- a/src/actions/user/support.ts +++ b/src/actions/user/support.ts @@ -1,10 +1,12 @@ "use server"; +import { Prisma } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; import { requireAuth } from "@/lib/require-auth"; import { actorFromSession, recordAuditLog } from "@/services/audit"; +import { getAppConfig } from "@/services/app-config"; import { createNotification } from "@/services/notifications"; import { createSupportAttachments, @@ -42,30 +44,50 @@ export async function createSupportTicket(formData: FormData) { const body = riskEvent ? data.body + "\n\n关联订阅风控事件:" + riskEvent.id + "\n系统判定:" + riskEvent.message : data.body; + const config = await getAppConfig(); + const supportOpenTicketLimit = Math.max(1, config.supportOpenTicketLimit); - const ticket = await prisma.supportTicket.create({ - data: { - userId: session.user.id, - subject: data.subject, - category: data.category || (riskEvent ? "订阅风控" : null), - priority: riskEvent ? "HIGH" : data.priority, - status: "OPEN", - lastReplyAt: new Date(), - replies: { - create: { - authorUserId: session.user.id, - isAdmin: false, - body, + const ticket = await prisma.$transaction( + async (tx) => { + const openTicketCount = await tx.supportTicket.count({ + where: { + userId: session.user.id, + status: { not: "CLOSED" }, }, - }, + }); + + if (openTicketCount >= supportOpenTicketLimit) { + throw new Error( + `你当前已有 ${openTicketCount} 个未关闭工单,最多只能同时保留 ${supportOpenTicketLimit} 个。请先关闭已处理工单或等待客服处理。`, + ); + } + + return tx.supportTicket.create({ + data: { + userId: session.user.id, + subject: data.subject, + category: data.category || (riskEvent ? "订阅风控" : null), + priority: riskEvent ? "HIGH" : data.priority, + status: "OPEN", + lastReplyAt: new Date(), + replies: { + create: { + authorUserId: session.user.id, + isAdmin: false, + body, + }, + }, + }, + include: { + replies: { + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }); }, - include: { - replies: { - orderBy: { createdAt: "desc" }, - take: 1, - }, - }, - }); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); const firstReply = ticket.replies[0]; if (firstReply && attachments.length > 0) { await createSupportAttachments({ diff --git a/src/app/(admin)/admin/announcements/announcement-form.tsx b/src/app/(admin)/admin/announcements/announcement-form.tsx index 03ad90c..5744ad2 100644 --- a/src/app/(admin)/admin/announcements/announcement-form.tsx +++ b/src/app/(admin)/admin/announcements/announcement-form.tsx @@ -10,6 +10,7 @@ import { createAnnouncement, updateAnnouncement, } from "@/actions/admin/announcements"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -210,9 +211,9 @@ export function AnnouncementForm({ - + @@ -343,9 +344,9 @@ export function CreateAnnouncementButton({ - + diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx index eaacf8f..64ba487 100644 --- a/src/app/(admin)/admin/commerce/page.tsx +++ b/src/app/(admin)/admin/commerce/page.tsx @@ -4,7 +4,7 @@ import { createCoupon, createPromotionRule } from "@/actions/admin/commerce"; import { DetailItem, DetailList } from "@/components/admin/detail-list"; import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge"; import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; -import { Button } from "@/components/ui/button"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -73,7 +73,7 @@ export default async function AdminCommercePage() { - + 创建优惠券
@@ -96,7 +96,7 @@ export default async function AdminCommercePage() { - + 创建满减
diff --git a/src/app/(admin)/admin/nodes/node-form.tsx b/src/app/(admin)/admin/nodes/node-form.tsx index 498fd15..e28eb8d 100644 --- a/src/app/(admin)/admin/nodes/node-form.tsx +++ b/src/app/(admin)/admin/nodes/node-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -98,9 +99,9 @@ export function NodeForm({

延迟和线路探测仍使用探测 Token;节点开通、暂停、删除客户端均回归 3x-ui 面板 API。

- + diff --git a/src/app/(admin)/admin/services/service-form.tsx b/src/app/(admin)/admin/services/service-form.tsx index aa9ed81..7c27ec8 100644 --- a/src/app/(admin)/admin/services/service-form.tsx +++ b/src/app/(admin)/admin/services/service-form.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { StreamingService } from "@prisma/client"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -80,9 +81,9 @@ export function ServiceForm({ - + diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index 7589da6..04ff2ca 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -27,6 +27,7 @@ export default async function AdminSettingsPage() { siteUrl: config.siteUrl, subscriptionUrl: config.subscriptionUrl, supportContact: config.supportContact, + supportOpenTicketLimit: config.supportOpenTicketLimit, maintenanceNotice: config.maintenanceNotice, siteNotice: config.siteNotice, allowRegistration: config.allowRegistration, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index 302d1b4..5e09142 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; -import { Bell, Clock3, Gift, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react"; +import { Bell, Clock3, Gift, LifeBuoy, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react"; import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,6 +16,7 @@ interface AppConfig { siteUrl: string | null; subscriptionUrl: string | null; supportContact: string | null; + supportOpenTicketLimit: number; maintenanceNotice: string | null; siteNotice: string | null; allowRegistration: boolean; @@ -65,6 +66,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: async function handleSubmit(event: FormEvent) { event.preventDefault(); + if (saving) return; const form = event.currentTarget; setSaving(true); @@ -85,6 +87,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: } async function handleTestEmail() { + if (testingEmail) return; + const form = document.getElementById("app-settings-form") as HTMLFormElement | null; if (!form) return; @@ -158,6 +162,29 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: +
+
+ 工单售后 +
+
+
+ + +

+ 用户最多可同时保留的未关闭工单,默认 2;关闭后可再次创建。 +

+
+
+
+
自动化任务 diff --git a/src/app/(admin)/admin/support/_components/admin-support-reply-form.tsx b/src/app/(admin)/admin/support/_components/admin-support-reply-form.tsx index 61d9a0a..2a403f7 100644 --- a/src/app/(admin)/admin/support/_components/admin-support-reply-form.tsx +++ b/src/app/(admin)/admin/support/_components/admin-support-reply-form.tsx @@ -1,6 +1,6 @@ import { Paperclip, Send } from "lucide-react"; import { replySupportAsAdmin } from "@/actions/admin/support"; -import { Button } from "@/components/ui/button"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -42,7 +42,7 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) { 仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。

- + 发送回复 ); } diff --git a/src/app/(admin)/admin/support/_components/admin-support-table.tsx b/src/app/(admin)/admin/support/_components/admin-support-table.tsx index cf80b42..00083d9 100644 --- a/src/app/(admin)/admin/support/_components/admin-support-table.tsx +++ b/src/app/(admin)/admin/support/_components/admin-support-table.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { Eye } from "lucide-react"; import { DataTableShell } from "@/components/admin/data-table-shell"; import { DataTable, @@ -14,6 +15,7 @@ import { SupportTicketStatusBadge, } from "@/components/support/ticket-badges"; import { AdminSupportTicketActions } from "@/components/support/admin-ticket-actions"; +import { buttonVariants } from "@/components/ui/button"; import { formatDate } from "@/lib/utils"; import type { AdminSupportTicketRow } from "../support-data"; @@ -63,7 +65,14 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) { -
+
+ + + 查看详情 +
diff --git a/src/app/(admin)/admin/support/_components/support-ticket-meta-form.tsx b/src/app/(admin)/admin/support/_components/support-ticket-meta-form.tsx index b2a8b39..4e5d723 100644 --- a/src/app/(admin)/admin/support/_components/support-ticket-meta-form.tsx +++ b/src/app/(admin)/admin/support/_components/support-ticket-meta-form.tsx @@ -1,5 +1,5 @@ import { updateSupportTicketMeta } from "@/actions/admin/support"; -import { Button } from "@/components/ui/button"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Label } from "@/components/ui/label"; import type { AdminSupportTicketDetail } from "../support-data"; @@ -38,7 +38,7 @@ export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDe
- + 更新状态 ); } diff --git a/src/app/(admin)/admin/tasks/_components/task-launch-panel.tsx b/src/app/(admin)/admin/tasks/_components/task-launch-panel.tsx index 9ac1b4b..bfc9d0d 100644 --- a/src/app/(admin)/admin/tasks/_components/task-launch-panel.tsx +++ b/src/app/(admin)/admin/tasks/_components/task-launch-panel.tsx @@ -1,6 +1,6 @@ import { BellRing } from "lucide-react"; import { runReminderTask } from "@/actions/admin/tasks"; -import { Button } from "@/components/ui/button"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; export function TaskLaunchPanel() { return ( @@ -13,7 +13,7 @@ export function TaskLaunchPanel() {

提醒派发

检查即将到期订阅并生成提醒。

- + 派发提醒 ); diff --git a/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx b/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx index e9ab99a..03a5f28 100644 --- a/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx +++ b/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx @@ -11,7 +11,7 @@ import { DataTableRow, } from "@/components/shared/data-table"; import { TaskStatusBadge, taskKindLabels } from "@/components/shared/domain-badges"; -import { Button } from "@/components/ui/button"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { formatDate } from "@/lib/utils"; import type { AdminTaskRunRow } from "../tasks-data"; @@ -85,7 +85,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) { await retryTaskRun(task.id); }} > - + 重试 )} diff --git a/src/app/(admin)/admin/users/user-form.tsx b/src/app/(admin)/admin/users/user-form.tsx index 4c31c95..6b4e475 100644 --- a/src/app/(admin)/admin/users/user-form.tsx +++ b/src/app/(admin)/admin/users/user-form.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { User } from "@prisma/client"; +import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -82,9 +83,9 @@ export function UserForm({ - + diff --git a/src/app/(user)/support/_components/create-support-ticket-form.tsx b/src/app/(user)/support/_components/create-support-ticket-form.tsx index 7722027..6ee26d2 100644 --- a/src/app/(user)/support/_components/create-support-ticket-form.tsx +++ b/src/app/(user)/support/_components/create-support-ticket-form.tsx @@ -1,12 +1,15 @@ "use client"; -import { useState } from "react"; +import { useRef, useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; import { Plus, X } from "lucide-react"; +import { toast } from "sonner"; import { createSupportTicket } from "@/actions/user/support"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { getErrorMessage } from "@/lib/errors"; const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif"; @@ -20,16 +23,66 @@ type SupportTicketPreset = { export function CreateSupportTicketForm({ defaultOpen = false, + openTicketCount = 0, + openTicketLimit = 2, preset, }: { defaultOpen?: boolean; + openTicketCount?: number; + openTicketLimit?: number; preset?: SupportTicketPreset; }) { + const router = useRouter(); const [open, setOpen] = useState(defaultOpen); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); + const effectiveOpenTicketLimit = Math.max(1, openTicketLimit); + const limitReached = openTicketCount >= effectiveOpenTicketLimit; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (submittingRef.current || limitReached) return; + + const form = event.currentTarget; + const formData = new FormData(form); + submittingRef.current = true; + setSubmitting(true); + + try { + await createSupportTicket(formData); + toast.success("工单已提交"); + form.reset(); + setOpen(false); + router.refresh(); + } catch (error) { + toast.error(getErrorMessage(error, "提交工单失败")); + } finally { + submittingRef.current = false; + setSubmitting(false); + } + } + + if (limitReached) { + return ( +
+
+ + + +
+

未关闭工单已达上限

+

+ 当前有 {openTicketCount}/{effectiveOpenTicketLimit} 个未关闭工单,请先关闭已处理工单或等待客服处理后再创建。 +

+
+
+
+ ); + } if (!open) { return ( - @@ -40,60 +93,69 @@ export function CreateSupportTicketForm({
void handleSubmit(event)} + aria-busy={submitting} className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6" > -
+

{preset?.riskEventId ? "订阅风控复核工单" : "新建工单"}

{!preset?.riskEventId && ( - + )}
-
-
- - + +
+
+
+ + +
+
+ + +
- - + +
-
-
- - -
-
- -