fix: prevent duplicate support ticket submissions

This commit is contained in:
JetSprow
2026-04-29 16:38:31 +10:00
parent 2a3c9959bd
commit 16573c67c3
24 changed files with 327 additions and 120 deletions

View File

@@ -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<typeof settingsSchema>, 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");

View File

@@ -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({