src/server/services/ticket.ts
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
import { TRPCError } from '@trpc/server';
import type { PrismaClient, Prisma } from '@/generated/prisma/client';
import {
statusChangeIssue,
type CreateTicketInput,
type TicketStatus,
} from '@/shared/ticket';
import { ticketWhere } from '@/server/ticket-filter';
import {
defaultDueAt,
scheduleReminder,
invalidateReminders,
} from '@/server/jobs/outbox';
import { recordTicketEvent } from './ticket-events';
import { requireMembership } from './access';
// 显式选择对外字段,避免未来模型加入内部字段时无意暴露给浏览器。
const ticketSelect = {
id: true,
number: true,
title: true,
description: true,
priority: true,
status: true,
version: true,
createdAt: true,
updatedAt: true,
dueAt: true,
reminderRevision: true,
assigneeMembershipId: true,
assignee: { select: { userId: true, user: { select: { name: true } } } },
} as const;
function serialize(
ticket: Prisma.TicketGetPayload<{ select: typeof ticketSelect }>,
) {
// HTTP 使用 JSON,时间转为 ISO 字符串;前端再用 Day.js 按本地时区展示。
return {
...ticket,
dueAt: ticket.dueAt?.toISOString() ?? null,
createdAt: ticket.createdAt.toISOString(),
updatedAt: ticket.updatedAt.toISOString(),
};
}
export function createTicketService(
db: PrismaClient,
userId: string,
teamId: string,
) {
return {
async create(input: CreateTicketInput) {
return db.$transaction(async (tx) => {
// 创建也与成员变更共用团队锁,避免检查成员后对方已被移除却仍创建工单。
await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${teamId}::uuid FOR UPDATE`;
const member = await requireMembership(tx, userId, teamId);
const ticket = await tx.ticket.create({
data: {
...input,
teamId,
creatorId: userId,
dueAt: defaultDueAt(input.priority),
},
select: ticketSelect,
});
await recordTicketEvent(tx, {
ticketId: ticket.id,
teamId,
version: ticket.version,
kind: 'CREATED',
actorUserId: userId,
actorRole: member.role,
toStatus: ticket.status,
});
await scheduleReminder(tx, {
id: ticket.id,
teamId,
creatorId: userId,
dueAt: ticket.dueAt,
reminderRevision: ticket.reminderRevision,
});
return serialize(ticket);
});
},
async list(input: {
page: number;
pageSize: number;
status?: TicketStatus;
q?: string;
priority?: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
assignment?: 'all' | 'mine' | 'unassigned';
}) {
const member = await requireMembership(db, userId, teamId);
// 团队条件永远存在;客户再加创建者条件,列表与总数使用同一个过滤条件。
const where = ticketWhere(teamId, member, input);
// 同一事务快照内读取列表和总数,避免有新工单插入时两者不一致。
// skip/take 对应前端分页的偏移量与每页条数;排序加编号确保结果稳定。
const [items, total] = await db.$transaction(
[
db.ticket.findMany({
where,
select: ticketSelect,
orderBy: [{ createdAt: 'desc' }, { number: 'desc' }],
skip: (input.page - 1) * input.pageSize,
take: input.pageSize,
}),
db.ticket.count({ where }),
],
{ isolationLevel: 'RepeatableRead' },
);
return { items: items.map(serialize), total };
},
async detail(id: string) {
const member = await requireMembership(db, userId, teamId);
const ticket = await db.ticket.findFirst({
where: {
id,
teamId,
...(member.role === 'CUSTOMER' ? { creatorId: userId } : {}),
},
select: ticketSelect,
});
// 查不到是可预期的业务错误,返回 NOT_FOUND,而不是让页面一直显示加载中。
if (!ticket)
throw new TRPCError({
code: 'NOT_FOUND',
message: '工单不存在或已删除',
});
return serialize(ticket);
},
async updateStatus(input: {
id: string;
status: TicketStatus;
version: number;
reason: string;
}) {
return db.$transaction(async (tx) => {
// 与分配、回复共用团队锁:状态检查和写入之间不会插入关闭工单的竞争请求。
await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${teamId}::uuid FOR UPDATE`;
const member = await requireMembership(tx, userId, teamId);
if (member.role === 'CUSTOMER')
throw new TRPCError({
code: 'FORBIDDEN',
message: '仅管理员和客服可以修改工单状态',
});
// 详情与修改都带 teamId。仅凭工单 UUID 查数据,会造成“猜到编号就能越权”。
// 跨团队与不存在统一返回 NOT_FOUND,不泄露其他团队是否有该工单。
const ticket = await tx.ticket.findFirst({
where: { id: input.id, teamId },
select: ticketSelect,
});
if (!ticket)
throw new TRPCError({
code: 'NOT_FOUND',
message: '工单不存在或已删除',
});
if (ticket.version !== input.version)
throw new TRPCError({
code: 'CONFLICT',
message: '工单已被更新,请查看最新状态后重试',
});
const issue = statusChangeIssue(
ticket.status,
input.status,
input.reason,
);
if (issue) throw new TRPCError({ code: 'BAD_REQUEST', message: issue });
// 类比表单的“编辑版本”:只有版本仍匹配才写入,同时递增版本。
// 校验与写入之间可能有别的请求,用数据库条件更新避免旧页面覆盖新状态。
const updated = await tx.ticket.updateManyAndReturn({
where: { id: input.id, teamId, version: input.version },
data: {
status: input.status,
version: { increment: 1 },
...(['RESOLVED', 'CLOSED'].includes(input.status)
? { reminderRevision: { increment: 1 } }
: ticket.status === 'RESOLVED' && input.status === 'OPEN'
? {
reminderRevision: { increment: 1 },
dueAt: defaultDueAt(ticket.priority),
}
: {}),
},
select: ticketSelect,
});
if (!updated[0])
throw new TRPCError({
code: 'CONFLICT',
message: '工单已被更新,请查看最新状态后重试',
});
await recordTicketEvent(tx, {
ticketId: input.id,
teamId,
version: updated[0].version,
kind: 'STATUS_CHANGED',
actorUserId: userId,
actorRole: member.role,
fromStatus: ticket.status,
toStatus: input.status,
reason: input.reason || undefined,
});
if (['RESOLVED', 'CLOSED'].includes(input.status))
await invalidateReminders(tx, teamId, input.id);
if (ticket.status === 'RESOLVED' && input.status === 'OPEN') {
await scheduleReminder(tx, {
id: input.id,
teamId,
creatorId: userId,
dueAt: updated[0].dueAt,
reminderRevision: updated[0].reminderRevision,
});
}
return serialize(updated[0]);
});
},
};
}