TTeamDesk 学习手册

src/server/services/ticket.ts

此代码来自构建时的仓库快照。修改源码后重新构建即可更新。

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]);
      });
    },
  };
}

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。