src/server/services/collaboration.ts
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
import { TRPCError } from '@trpc/server';
import type { Prisma, PrismaClient } from '@/generated/prisma/client';
import { requireMembership, requireTicketAccess } from './access';
import { recordTicketEvent } from './ticket-events';
const replySelect = {
id: true,
content: true,
authorId: true,
authorName: true,
authorRole: true,
createdAt: true,
} as const;
function serializeReply(
reply: Prisma.TicketReplyGetPayload<{ select: typeof replySelect }>,
) {
return { ...reply, createdAt: reply.createdAt.toISOString() };
}
export function createCollaborationService(
db: PrismaClient,
userId: string,
teamId: string,
) {
return {
async assignees() {
const member = await requireMembership(db, userId, teamId);
if (member.role === 'CUSTOMER')
throw new TRPCError({ code: 'FORBIDDEN', message: '客户不能分配工单' });
return db.membership.findMany({
where: { teamId, role: { in: ['ADMIN', 'AGENT'] } },
select: { id: true, userId: true, user: { select: { name: true } } },
orderBy: { createdAt: 'asc' },
});
},
async assign(input: {
id: string;
assigneeId: string | null;
version: number;
}) {
return db.$transaction(async (tx) => {
// 成员变更也持有同一把团队锁,防止验证候选人后,对方立刻被移除或降级。
await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${teamId}::uuid FOR UPDATE`;
const { member, ticket } = await requireTicketAccess(
tx,
userId,
teamId,
input.id,
);
if (member.role === 'CUSTOMER')
throw new TRPCError({
code: 'FORBIDDEN',
message: '仅管理员和客服可以分配工单',
});
if (ticket.status === 'CLOSED')
throw new TRPCError({
code: 'BAD_REQUEST',
message: '工单已关闭,不能再修改分配',
});
if (ticket.version !== input.version)
throw new TRPCError({
code: 'CONFLICT',
message: '工单已更新,请刷新后重新分配',
});
// 重复选择当前负责人不产生新版本和假操作记录。
if (ticket.assigneeMembershipId === input.assigneeId)
return { success: true };
let toAssigneeName: string | undefined;
if (input.assigneeId) {
const target = await tx.membership.findFirst({
where: {
id: input.assigneeId,
teamId,
role: { in: ['ADMIN', 'AGENT'] },
},
include: { user: { select: { name: true } } },
});
if (!target)
throw new TRPCError({
code: 'BAD_REQUEST',
message: '负责人必须是本团队的客服或管理员',
});
toAssigneeName = target.user.name;
}
const previous = ticket.assigneeMembershipId
? await tx.membership.findUnique({
where: { id: ticket.assigneeMembershipId },
select: { user: { select: { name: true } } },
})
: null;
// 更新时仍带团队和版本条件,避免未来调用方式变更后出现跨团队写入。
const result = await tx.ticket.updateMany({
where: { id: input.id, teamId, version: input.version },
data: {
assigneeMembershipId: input.assigneeId,
version: { increment: 1 },
},
});
if (!result.count)
throw new TRPCError({
code: 'CONFLICT',
message: '工单已更新,请刷新后重试',
});
await recordTicketEvent(tx, {
teamId,
ticketId: input.id,
version: ticket.version + 1,
kind: input.assigneeId ? 'ASSIGNED' : 'UNASSIGNED',
actorUserId: userId,
actorRole: member.role,
fromAssigneeName: previous?.user.name,
toAssigneeName,
});
return { success: true };
});
},
async replies(input: { id: string; page: number; pageSize: number }) {
return db.$transaction(
async (tx) => {
await requireTicketAccess(tx, userId, teamId, input.id);
// 一次返回固定页数,避免对话变长后读取全部历史;排序加 id 保证同一时间的记录也稳定。
const where = { teamId, ticketId: input.id };
const items = await tx.ticketReply.findMany({
where,
select: replySelect,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (input.page - 1) * input.pageSize,
take: input.pageSize,
});
const total = await tx.ticketReply.count({ where });
return { items: items.map(serializeReply), total };
},
{ isolationLevel: 'RepeatableRead' },
);
},
async reply(input: {
id: string;
content: string;
clientRequestId: string;
}) {
return db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${teamId}::uuid FOR UPDATE`;
const { ticket, member } = await requireTicketAccess(
tx,
userId,
teamId,
input.id,
);
// 网络超时不代表写入失败。相同请求编号重试时返回已保存的回复,避免产生重复消息。
const existing = await tx.ticketReply.findFirst({
where: {
teamId,
ticketId: input.id,
authorId: userId,
clientRequestId: input.clientRequestId,
},
select: replySelect,
});
if (existing) {
if (existing.content !== input.content)
throw new TRPCError({
code: 'CONFLICT',
message: '该请求编号已使用,请刷新后重试',
});
return serializeReply(existing);
}
if (ticket.status === 'CLOSED')
throw new TRPCError({
code: 'BAD_REQUEST',
message: '工单已关闭,不能继续回复',
});
const author = await tx.user.findUniqueOrThrow({
where: { id: userId },
select: { name: true },
});
// 作者和角色来自会话与数据库,不接受客户端填写;姓名快照让历史回复保持可读。
const reply = await tx.ticketReply.create({
data: {
ticketId: input.id,
teamId,
authorId: userId,
authorName: author.name,
authorRole: member.role,
content: input.content,
clientRequestId: input.clientRequestId,
},
select: replySelect,
});
// 回复和工单更新时间在同一事务提交;任一步失败都会回滚,不产生半完成记录。
const updated = await tx.ticket.update({
where: { id: input.id, teamId },
data: { updatedAt: new Date(), version: { increment: 1 } },
});
await recordTicketEvent(tx, {
teamId,
ticketId: input.id,
version: updated.version,
kind: 'REPLIED',
actorUserId: userId,
actorRole: member.role,
replyId: reply.id,
});
return serializeReply(reply);
});
},
};
}