# 工单系统架构设计

> 架构设计文档 - 定义工单系统的技术架构、数据模型和核心设计决策
>
> **版本**: v1.3.1  
> **模块标识**: `platform_tickets`  
> **创建日期**: 2025-12-11  
> **更新日期**: 2026-01-25

---

## 🏗️ 系统架构

### 整体架构图

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                                  前端应用层                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐        │
│  │  工单创建    │  │  工单列表    │  │  工单详情    │  │  统计仪表盘   │        │
│  │  TicketForm  │  │  TicketList  │  │ TicketDetail │  │  Dashboard   │        │
│  └──────────────┘  └──────────────┘  └──────────────┘  └──────────────┘        │
│                                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                          │
│  │  配置管理    │  │  分配规则    │  │  SLA 配置    │                          │
│  │  AdminConfig │  │ AssignRules  │  │  SLAConfig   │                          │
│  └──────────────┘  └──────────────┘  └──────────────┘                          │
└──────────────────────────────────────────────────────────────────────────────────┘
                                        │
                                        │ REST API / WebSocket
                                        ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│                                  API 层 (NestJS)                                 │
│  ┌──────────────────────────────────────────────────────────────────────────┐   │
│  │                          tickets.controller.ts                            │   │
│  │  POST /tickets              - 创建工单                                    │   │
│  │  GET  /tickets              - 查询工单列表                                │   │
│  │  GET  /tickets/:id          - 获取工单详情                                │   │
│  │  PATCH /tickets/:id         - 更新工单                                    │   │
│  │  POST /tickets/:id/assign   - 分配工单                                    │   │
│  │  POST /tickets/:id/comments - 添加评论                                    │   │
│  └──────────────────────────────────────────────────────────────────────────┘   │
│                                                                                  │
│  ┌──────────────────────────────────────────────────────────────────────────┐   │
│  │                        ticket-admin.controller.ts                         │   │
│  │  管理端接口：分类管理、处理组管理、SLA 配置、统计报表                       │   │
│  └──────────────────────────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────────────────────────┘
                                        │
                                        ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│                                  服务层                                          │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  ┌──────────────┐   │
│  │ TicketService  │  │ AssignService  │  │  SLAService    │  │ StatsService │   │
│  │   工单管理     │  │   分配引擎     │  │  SLA 监控      │  │   统计分析   │   │
│  └────────────────┘  └────────────────┘  └────────────────┘  └──────────────┘   │
│                                                                                  │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐                     │
│  │ CommentService │  │CategoryService │  │ GroupService   │                     │
│  │   评论管理     │  │   分类管理     │  │  处理组管理    │                     │
│  └────────────────┘  └────────────────┘  └────────────────┘                     │
└──────────────────────────────────────────────────────────────────────────────────┘
                                        │
                                        ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│                                 集成层                                           │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  ┌──────────────┐   │
│  │ NotificationSvc│  │ ApprovalSvc    │  │ OrganizationSvc│  │ AuditService │   │
│  │  通知服务      │  │  审批服务      │  │   组织服务     │  │  审计服务    │   │
│  └────────────────┘  └────────────────┘  └────────────────┘  └──────────────┘   │
│                                                                                  │
│  ┌────────────────┐                                                              │
│  │AIAssistantSvc  │  🆕 AI 智能助手集成（对话升级、分类推荐）                     │
│  └────────────────┘                                                              │
└──────────────────────────────────────────────────────────────────────────────────┘
                                        │
                                        ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│                                 数据层                                           │
│  ┌────────────────────────────────────────────────────────────────────────────┐ │
│  │                            PostgreSQL                                       │ │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐         │ │
│  │  │ Ticket   │ │ Category │ │ Comment  │ │ SLA      │ │ Group    │         │ │
│  │  └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘         │ │
│  └────────────────────────────────────────────────────────────────────────────┘ │
│                                                                                  │
│  ┌────────────────────────────────────────────────────────────────────────────┐ │
│  │                              Redis                                          │ │
│  │  - 工单计数器（生成编号）                                                    │ │
│  │  - SLA 计时缓存                                                             │ │
│  │  - 分配状态缓存                                                             │ │
│  └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
```

### 模块划分

```
backend/src/modules/tickets/
├── docs/                           # 📝 模块文档
│   ├── README.md                   # 模块索引
│   ├── PRD.md                      # 产品需求
│   ├── ARCHITECTURE.md             # 架构设计（本文档）
│   ├── API.md                      # API 接口
│   └── TODO.md                     # 开发待办
│
├── tickets.module.ts               # 模块定义
├── tickets.controller.ts           # 工单 API
├── tickets.service.ts              # 工单服务
├── ticket-admin.controller.ts      # 管理端 API
│
├── dto/                            # 数据传输对象
│   ├── create-ticket.dto.ts
│   ├── update-ticket.dto.ts
│   ├── query-ticket.dto.ts
│   ├── assign-ticket.dto.ts
│   └── index.ts
│
├── entities/                       # 类型定义
│   ├── ticket.entity.ts
│   ├── ticket-category.entity.ts
│   ├── ticket-comment.entity.ts
│   └── index.ts
│
├── services/                       # 子服务
│   ├── assign.service.ts           # 分配服务
│   ├── sla.service.ts              # SLA 服务
│   ├── comment.service.ts          # 评论服务
│   ├── category.service.ts         # 分类服务
│   ├── group.service.ts            # 处理组服务
│   └── stats.service.ts            # 统计服务
│
├── strategies/                     # 分配策略
│   ├── assignment.strategy.ts      # 策略接口
│   ├── round-robin.strategy.ts     # 轮询策略
│   ├── load-balance.strategy.ts    # 负载均衡策略
│   └── skill-based.strategy.ts     # 技能匹配策略
│
├── events/                         # 事件定义
│   ├── ticket.events.ts
│   └── ticket.listeners.ts
│
└── exceptions/                     # 异常定义
    └── ticket.exceptions.ts
```

---

## 🗄️ 数据模型

### ER 图

```
┌─────────────────┐       ┌─────────────────┐       ┌─────────────────┐
│  TicketCategory │       │     Ticket      │       │  TicketComment  │
├─────────────────┤       ├─────────────────┤       ├─────────────────┤
│ id              │◄──────│ categoryId      │       │ id              │
│ name            │       │ id              │◄──────│ ticketId        │
│ code            │       │ ticketNo        │       │ content         │
│ parentId   ────►│       │ title           │       │ type            │
│ defaultPriority │       │ description     │       │ isInternal      │
│ slaId      ────►│       │ priority        │       │ authorId        │
│ ...             │       │ status          │       │ attachments     │
└─────────────────┘       │ creatorId       │       │ mentionedUserIds│
                          │ assigneeId      │       │ createdAt       │
┌─────────────────┐       │ assigneeGroupId │       └─────────────────┘
│ AssignmentGroup │       │ slaId      ────►│
├─────────────────┤       │ region          │       ┌─────────────────┐
│ id              │◄──────│ assigneeGroupId │       │   TicketSLA     │
│ name            │       │ ...             │       ├─────────────────┤
│ code            │       └─────────────────┘       │ id              │
│ memberIds       │                                 │ name            │
│ managerId       │       ┌─────────────────┐       │ firstResponseTime│
│ strategy        │       │  TicketActivity │       │ resolutionTime  │
│ categoryIds     │       ├─────────────────┤       │ businessHours   │
│ ...             │       │ id              │       │ escalationRules │
└─────────────────┘       │ ticketId        │       │ ...             │
                          │ type            │       └─────────────────┘
                          │ content         │
                          │ operatorId      │       ┌─────────────────┐
                          │ createdAt       │       │ TicketAttachment│
                          └─────────────────┘       ├─────────────────┤
                                                    │ id              │
┌─────────────────┐                                 │ ticketId        │
│ TicketSatisfaction│                               │ commentId       │
├─────────────────┤                                 │ fileName        │
│ id              │                                 │ fileUrl         │
│ ticketId        │                                 │ fileSize        │
│ rating          │                                 │ mimeType        │
│ comment         │                                 │ uploadedBy      │
│ createdAt       │                                 │ createdAt       │
└─────────────────┘                                 └─────────────────┘
```

### 数据库 Schema (Prisma)

```prisma
// backend/prisma/schema/platform_tickets.prisma

// ============================================================================
// 工单系统 (Ticket System)
// ============================================================================

/// 工单分类
model TicketCategory {
  id                    String              @id @default(uuid()) @db.Uuid
  name                  String              @db.VarChar(100)
  code                  String              @unique @db.VarChar(50)
  description           String?             @db.Text
  icon                  String?             @db.VarChar(50)
  
  // 层级关系
  parentId              String?             @db.Uuid @map("parent_id")
  parent                TicketCategory?     @relation("CategoryHierarchy", fields: [parentId], references: [id])
  children              TicketCategory[]    @relation("CategoryHierarchy")
  
  // 默认配置
  defaultPriority       TicketPriority      @default(MEDIUM) @map("default_priority")
  defaultAssigneeGroupId String?            @db.Uuid @map("default_assignee_group_id")
  defaultAssigneeGroup  AssignmentGroup?    @relation(fields: [defaultAssigneeGroupId], references: [id])
  slaId                 String?             @db.Uuid @map("sla_id")
  sla                   TicketSLA?          @relation(fields: [slaId], references: [id])
  formTemplateId        String?             @db.Uuid @map("form_template_id")
  
  // 可见性控制
  allowedDepartmentIds  String[]            @default([]) @db.Uuid @map("allowed_department_ids")
  allowedRoleIds        String[]            @default([]) @db.Uuid @map("allowed_role_ids")
  
  // 是否需要审批
  requiresApproval      Boolean             @default(false) @map("requires_approval")
  approvalProcessKey    String?             @db.VarChar(100) @map("approval_process_key")
  
  // 状态
  isActive              Boolean             @default(true) @map("is_active")
  sortOrder             Int                 @default(0) @map("sort_order")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")
  updatedAt             DateTime            @updatedAt @map("updated_at")
  
  // 关联
  tickets               Ticket[]

  @@map("ticket_categories")
  @@schema("platform_tickets")
}

/// 工单
model Ticket {
  id                    String              @id @default(uuid()) @db.Uuid
  ticketNo              String              @unique @db.VarChar(30) @map("ticket_no")
  
  // 基本信息
  title                 String              @db.VarChar(200)
  description           String              @db.Text
  
  // 分类与优先级
  categoryId            String              @db.Uuid @map("category_id")
  category              TicketCategory      @relation(fields: [categoryId], references: [id])
  priority              TicketPriority      @default(MEDIUM)
  tags                  String[]            @default([])
  
  // 状态
  status                TicketStatus        @default(OPEN)
  
  // 人员
  creatorId             String              @db.Uuid @map("creator_id")
  assigneeId            String?             @db.Uuid @map("assignee_id")
  assigneeGroupId       String?             @db.Uuid @map("assignee_group_id")
  assigneeGroup         AssignmentGroup?    @relation(fields: [assigneeGroupId], references: [id])
  watcherIds            String[]            @default([]) @db.Uuid @map("watcher_ids")
  
  // 时间
  createdAt             DateTime            @default(now()) @map("created_at")
  updatedAt             DateTime            @updatedAt @map("updated_at")
  firstResponseAt       DateTime?           @map("first_response_at")
  resolvedAt            DateTime?           @map("resolved_at")
  closedAt              DateTime?           @map("closed_at")
  dueAt                 DateTime?           @map("due_at")
  
  // SLA
  slaId                 String?             @db.Uuid @map("sla_id")
  sla                   TicketSLA?          @relation(fields: [slaId], references: [id])
  slaBreached           Boolean             @default(false) @map("sla_breached")
  slaPausedAt           DateTime?           @map("sla_paused_at")
  slaPausedDuration     Int                 @default(0) @map("sla_paused_duration") // 秒
  
  // 来源
  source                TicketSource        @default(WEB)
  externalId            String?             @db.VarChar(100) @map("external_id")
  channelMetadata       Json?               @map("channel_metadata")  // 🆕 来源渠道元数据
  
  // 解决方案
  resolution            String?             @db.Text
  rootCause             String?             @db.Text @map("root_cause")
  
  // 关联
  relatedTicketIds      String[]            @default([]) @db.Uuid @map("related_ticket_ids")
  parentTicketId        String?             @db.Uuid @map("parent_ticket_id")
  parentTicket          Ticket?             @relation("TicketHierarchy", fields: [parentTicketId], references: [id])
  childTickets          Ticket[]            @relation("TicketHierarchy")
  
  // 审批关联
  approvalInstanceId    String?             @db.Uuid @map("approval_instance_id")
  
  // 多区域/多租户
  region                String              @default("CN") @db.VarChar(10)
  tenantId              String?             @db.Uuid @map("tenant_id")  // 🆕 租户 ID
  language              String?             @db.VarChar(10)             // 🆕 工单语言
  
  // 合规标记
  complianceFlag        Boolean             @default(false) @map("compliance_flag")  // 🆕 合规标记（不可物理删除）
  
  // 软删除
  deletedAt             DateTime?           @map("deleted_at")
  
  // 关联
  comments              TicketComment[]
  activities            TicketActivity[]
  attachments           TicketAttachment[]
  satisfaction          TicketSatisfaction?

  @@index([ticketNo])
  @@index([status])
  @@index([categoryId])
  @@index([creatorId])
  @@index([assigneeId])
  @@index([createdAt])
  @@index([region])
  @@index([tenantId])                        // 🆕 租户索引
  @@index([tenantId, region])                // 🆕 租户+区域复合索引
  @@map("tickets")
  @@schema("platform_tickets")
}

/// 工单评论
model TicketComment {
  id                    String              @id @default(uuid()) @db.Uuid
  ticketId              String              @db.Uuid @map("ticket_id")
  ticket                Ticket              @relation(fields: [ticketId], references: [id])
  
  // 内容
  content               String              @db.Text
  
  // 类型
  type                  CommentType         @default(COMMENT)
  isInternal            Boolean             @default(false) @map("is_internal")
  visibilityScope       VisibilityScope     @default(ALL) @map("visibility_scope")  // 🆕 可见范围
  
  // 作者
  authorId              String              @db.Uuid @map("author_id")
  
  // @提醒
  mentionedUserIds      String[]            @default([]) @db.Uuid @map("mentioned_user_ids")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")
  updatedAt             DateTime            @updatedAt @map("updated_at")
  deletedAt             DateTime?           @map("deleted_at")
  
  // 关联
  attachments           TicketAttachment[]

  @@index([ticketId])
  @@map("ticket_comments")
  @@schema("platform_tickets")
}

/// 工单附件
model TicketAttachment {
  id                    String              @id @default(uuid()) @db.Uuid
  ticketId              String              @db.Uuid @map("ticket_id")
  ticket                Ticket              @relation(fields: [ticketId], references: [id])
  commentId             String?             @db.Uuid @map("comment_id")
  comment               TicketComment?      @relation(fields: [commentId], references: [id])
  
  // 文件信息
  fileName              String              @db.VarChar(255) @map("file_name")
  fileUrl               String              @db.VarChar(500) @map("file_url")
  fileSize              Int                 @map("file_size")
  mimeType              String              @db.VarChar(100) @map("mime_type")
  
  // 上传者
  uploadedBy            String              @db.Uuid @map("uploaded_by")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")

  @@index([ticketId])
  @@map("ticket_attachments")
  @@schema("platform_tickets")
}

/// 工单活动日志
model TicketActivity {
  id                    String              @id @default(uuid()) @db.Uuid
  ticketId              String              @db.Uuid @map("ticket_id")
  ticket                Ticket              @relation(fields: [ticketId], references: [id])
  
  // 活动类型
  type                  ActivityType
  
  // 活动内容
  content               Json                // 包含变更详情
  
  // 操作者
  operatorId            String              @db.Uuid @map("operator_id")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")

  @@index([ticketId])
  @@index([createdAt])
  @@map("ticket_activities")
  @@schema("platform_tickets")
}

/// SLA 配置
model TicketSLA {
  id                    String              @id @default(uuid()) @db.Uuid
  name                  String              @db.VarChar(100)
  description           String?             @db.Text
  
  // 响应时间（分钟）
  firstResponseTime     Json                @map("first_response_time") // { low, medium, high, urgent }
  
  // 解决时间（分钟）
  resolutionTime        Json                @map("resolution_time")     // { low, medium, high, urgent }
  
  // 工作时间配置
  businessHours         Json                @map("business_hours")      // { start, end, timezone, weekdays }
  excludeHolidays       Boolean             @default(true) @map("exclude_holidays")
  holidayCalendarId     String?             @db.Uuid @map("holiday_calendar_id")
  
  // 升级策略
  escalationRules       Json?               @map("escalation_rules")
  
  // 状态
  isActive              Boolean             @default(true) @map("is_active")
  isDefault             Boolean             @default(false) @map("is_default")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")
  updatedAt             DateTime            @updatedAt @map("updated_at")
  
  // 关联
  categories            TicketCategory[]
  tickets               Ticket[]

  @@map("ticket_slas")
  @@schema("platform_tickets")
}

/// 处理组
model AssignmentGroup {
  id                    String              @id @default(uuid()) @db.Uuid
  name                  String              @db.VarChar(100)
  code                  String              @unique @db.VarChar(50)
  description           String?             @db.Text
  
  // 成员
  memberIds             String[]            @db.Uuid @map("member_ids")
  managerId             String?             @db.Uuid @map("manager_id")
  
  // 技能
  skills                String[]            @default([])
  
  // 分配策略
  assignmentStrategy    AssignmentStrategy  @default(ROUND_ROBIN) @map("assignment_strategy")
  
  // 关联分类
  categoryIds           String[]            @default([]) @db.Uuid @map("category_ids")
  
  // 状态
  isActive              Boolean             @default(true) @map("is_active")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")
  updatedAt             DateTime            @updatedAt @map("updated_at")
  
  // 关联
  categories            TicketCategory[]
  tickets               Ticket[]

  @@map("assignment_groups")
  @@schema("platform_tickets")
}

/// 满意度评价
model TicketSatisfaction {
  id                    String              @id @default(uuid()) @db.Uuid
  ticketId              String              @unique @db.Uuid @map("ticket_id")
  ticket                Ticket              @relation(fields: [ticketId], references: [id])
  
  // 评价
  rating                Int                 // 1-5
  comment               String?             @db.Text
  
  // 评价者
  ratedBy               String              @db.Uuid @map("rated_by")
  
  // 审计
  createdAt             DateTime            @default(now()) @map("created_at")

  @@map("ticket_satisfactions")
  @@schema("platform_tickets")
}

// ============================================================================
// 枚举定义
// ============================================================================

enum TicketStatus {
  OPEN
  ASSIGNED
  IN_PROGRESS
  PENDING
  RESOLVED
  CLOSED
  CANCELLED

  @@schema("platform_tickets")
}

enum TicketPriority {
  LOW
  MEDIUM
  HIGH
  URGENT

  @@schema("platform_tickets")
}

enum TicketSource {
  WEB
  MOBILE
  EMAIL
  API
  CHAT
  PHONE
  WALK_IN

  @@schema("platform_tickets")
}

enum CommentType {
  COMMENT
  STATUS_CHANGE
  ASSIGNMENT
  SYSTEM
  AI_CONTEXT        // 🆕 AI 对话上下文

  @@schema("platform_tickets")
}

// 🆕 评论可见范围
enum VisibilityScope {
  ALL               // 所有人可见（默认）
  AGENT_ONLY        // 仅处理人可见
  GROUP_ONLY        // 仅处理组可见
  INTERNAL          // 内部可见

  @@schema("platform_tickets")
}

enum ActivityType {
  CREATED
  STATUS_CHANGED
  ASSIGNED
  REASSIGNED
  PRIORITY_CHANGED
  COMMENTED
  ATTACHMENT_ADDED
  SLA_BREACHED
  ESCALATED
  RESOLVED
  CLOSED
  REOPENED

  @@schema("platform_tickets")
}

enum AssignmentStrategy {
  ROUND_ROBIN
  LOAD_BALANCE
  MANUAL
  SKILL_BASED

  @@schema("platform_tickets")
}
```

---

## 🔧 核心设计决策

### 决策 1: 工单编号生成策略

**问题**: 如何生成可读且唯一的工单编号？

**方案**: 采用 `TK-{YYYYMMDD}-{序号}` 格式

```typescript
// 使用 Redis 计数器保证唯一性
async function generateTicketNo(): Promise<string> {
  const date = format(new Date(), 'yyyyMMdd');
  const key = `ticket:counter:${date}`;
  
  const counter = await redis.incr(key);
  await redis.expire(key, 86400 * 2); // 保留 2 天
  
  const seq = counter.toString().padStart(4, '0');
  return `TK-${date}-${seq}`;
}
```

**理由**:
- ✅ 日期前缀便于识别和排序
- ✅ Redis 计数器保证并发安全
- ✅ 4 位序号支持每日 9999 个工单

### 决策 2: 分配策略模式

**问题**: 如何支持多种分配策略且易于扩展？

**方案**: 采用策略模式 (Strategy Pattern)

```typescript
// 策略接口
interface AssignmentStrategy {
  name: string;
  assign(
    ticket: Ticket,
    group: AssignmentGroup,
    context: AssignmentContext
  ): Promise<string | null>; // 返回 assigneeId
}

// 轮询策略
class RoundRobinStrategy implements AssignmentStrategy {
  name = 'ROUND_ROBIN';
  
  async assign(ticket, group, context): Promise<string | null> {
    const members = await getActiveMembers(group.id);
    const lastAssignee = await getLastAssignee(group.id);
    const nextIndex = (members.indexOf(lastAssignee) + 1) % members.length;
    return members[nextIndex];
  }
}

// 负载均衡策略
class LoadBalanceStrategy implements AssignmentStrategy {
  name = 'LOAD_BALANCE';
  
  async assign(ticket, group, context): Promise<string | null> {
    const members = await getActiveMembers(group.id);
    const workloads = await getWorkloads(members);
    return members.reduce((min, m) => 
      workloads[m] < workloads[min] ? m : min
    );
  }
}

// 策略工厂
class AssignmentStrategyFactory {
  private strategies: Map<string, AssignmentStrategy>;
  
  getStrategy(name: AssignmentStrategy): AssignmentStrategy {
    return this.strategies.get(name) || this.strategies.get('ROUND_ROBIN');
  }
}
```

**理由**:
- ✅ 符合开闭原则，易于添加新策略
- ✅ 策略独立测试
- ✅ 运行时切换策略

### 决策 3: SLA 计时机制

**问题**: 如何准确计算 SLA 时间，考虑工作时间和暂停？

**方案**: 分离 SLA 计时服务，支持暂停/恢复

```typescript
interface SLATimer {
  ticketId: string;
  slaId: string;
  startTime: Date;
  pausedAt?: Date;
  totalPausedDuration: number; // 秒
  dueAt: Date;
}

class SLAService {
  // 计算工作时间内的截止时间
  calculateDueTime(
    startTime: Date,
    targetMinutes: number,
    businessHours: BusinessHours,
    holidays: Date[]
  ): Date {
    let remainingMinutes = targetMinutes;
    let current = startTime;
    
    while (remainingMinutes > 0) {
      if (this.isWorkingTime(current, businessHours, holidays)) {
        remainingMinutes--;
      }
      current = addMinutes(current, 1);
    }
    
    return current;
  }
  
  // 暂停 SLA
  async pauseSLA(ticketId: string): Promise<void> {
    const timer = await this.getTimer(ticketId);
    timer.pausedAt = new Date();
    await this.saveTimer(timer);
  }
  
  // 恢复 SLA
  async resumeSLA(ticketId: string): Promise<void> {
    const timer = await this.getTimer(ticketId);
    if (timer.pausedAt) {
      timer.totalPausedDuration += differenceInSeconds(new Date(), timer.pausedAt);
      timer.pausedAt = null;
      timer.dueAt = this.recalculateDueTime(timer);
    }
    await this.saveTimer(timer);
  }
}
```

**理由**:
- ✅ 精确计算工作时间
- ✅ 支持暂停/恢复
- ✅ 考虑节假日

### 决策 4: 事件驱动的通知

**问题**: 如何在工单状态变更时触发通知？

**方案**: 使用 NestJS 事件系统

```typescript
// 事件定义
class TicketCreatedEvent {
  constructor(public readonly ticket: Ticket) {}
}

class TicketAssignedEvent {
  constructor(
    public readonly ticket: Ticket,
    public readonly assigneeId: string
  ) {}
}

class TicketStatusChangedEvent {
  constructor(
    public readonly ticket: Ticket,
    public readonly oldStatus: TicketStatus,
    public readonly newStatus: TicketStatus
  ) {}
}

// 事件监听器
@Injectable()
class TicketEventListener {
  constructor(
    private notificationService: NotificationService
  ) {}
  
  @OnEvent('ticket.created')
  async handleTicketCreated(event: TicketCreatedEvent) {
    // 通知处理组
    await this.notificationService.notify({
      type: 'TICKET_CREATED',
      recipients: await this.getRecipients(event.ticket),
      data: event.ticket,
    });
  }
  
  @OnEvent('ticket.assigned')
  async handleTicketAssigned(event: TicketAssignedEvent) {
    // 通知被分配人
    await this.notificationService.notify({
      type: 'TICKET_ASSIGNED',
      recipients: [event.assigneeId],
      data: event.ticket,
    });
  }
  
  @OnEvent('ticket.sla.warning')
  async handleSLAWarning(event: SLAWarningEvent) {
    // SLA 预警通知
    await this.notificationService.notify({
      type: 'TICKET_SLA_WARNING',
      recipients: [event.ticket.assigneeId, event.groupManagerId],
      data: event.ticket,
    });
  }
}
```

**理由**:
- ✅ 解耦工单逻辑和通知逻辑
- ✅ 易于添加新的事件处理
- ✅ 支持异步处理

### 决策 5: 数据权限控制

**问题**: 如何实现用户只能查看有权限的工单？

**方案**: 查询时注入权限过滤

```typescript
interface TicketQueryContext {
  userId: string;
  roles: string[];
  region?: string;
  groupIds?: string[];
}

class TicketService {
  async findAll(
    query: QueryTicketDto,
    context: TicketQueryContext
  ): Promise<PaginatedResult<Ticket>> {
    const where: Prisma.TicketWhereInput = {
      deletedAt: null,
    };
    
    // 权限过滤
    if (!context.roles.includes('Administrator')) {
      where.OR = [
        { creatorId: context.userId },        // 我创建的
        { assigneeId: context.userId },        // 分配给我的
        { watcherIds: { has: context.userId }}, // 我关注的
        { assigneeGroupId: { in: context.groupIds }}, // 我所在组的
      ];
      
      // 区域过滤
      if (context.region && context.roles.includes('TICKET_ADMIN')) {
        where.region = context.region;
      }
    }
    
    // 应用查询条件
    if (query.status) {
      where.status = query.status;
    }
    if (query.categoryId) {
      where.categoryId = query.categoryId;
    }
    // ...
    
    return this.prisma.ticket.findMany({ where, ...pagination });
  }
}
```

**理由**:
- ✅ 查询层面过滤，性能更好
- ✅ 支持复杂的权限规则
- ✅ 与业务逻辑解耦

---

## 🔗 模块集成

### 与通知引擎集成

```typescript
// 工单事件 → 通知类型映射
const TICKET_NOTIFICATION_MAP = {
  'ticket.created': 'TICKET_CREATED',
  'ticket.assigned': 'TICKET_ASSIGNED',
  'ticket.status_changed': 'TICKET_STATUS_CHANGED',
  'ticket.commented': 'TICKET_COMMENTED',
  'ticket.mentioned': 'TICKET_MENTIONED',
  'ticket.sla.warning': 'TICKET_SLA_WARNING',
  'ticket.sla.breached': 'TICKET_SLA_BREACHED',
  'ticket.resolved': 'TICKET_RESOLVED',
};

// 通知服务调用
async function sendTicketNotification(
  type: string,
  ticket: Ticket,
  additionalRecipients?: string[]
) {
  const recipients = await getNotificationRecipients(ticket, type);
  
  await notificationService.send({
    type: TICKET_NOTIFICATION_MAP[type],
    channels: ['EMAIL', 'IN_APP'],
    recipients: [...recipients, ...(additionalRecipients || [])],
    data: {
      ticketNo: ticket.ticketNo,
      title: ticket.title,
      status: ticket.status,
      url: `/tickets/${ticket.id}`,
    },
  });
}
```

### 与审批引擎集成

```typescript
// 需要审批的工单创建流程
async function createTicketWithApproval(
  dto: CreateTicketDto,
  userId: string
): Promise<Ticket> {
  const category = await categoryService.findById(dto.categoryId);
  
  // 创建工单
  const ticket = await ticketService.create(dto, userId);
  
  // 如果需要审批
  if (category.requiresApproval && category.approvalProcessKey) {
    const approval = await approvalService.startApproval({
      processDefinitionKey: category.approvalProcessKey,
      businessType: 'TICKET',
      businessId: ticket.id,
      businessKey: ticket.ticketNo,
      variables: {
        ticketTitle: ticket.title,
        ticketPriority: ticket.priority,
        categoryCode: category.code,
      },
      initiatorId: userId,
    });
    
    await ticketService.update(ticket.id, {
      approvalInstanceId: approval.id,
      status: 'PENDING_APPROVAL',
    });
  }
  
  return ticket;
}

// 审批完成回调
async function onApprovalCompleted(event: ApprovalCompletedEvent) {
  if (event.businessType !== 'TICKET') return;
  
  const ticket = await ticketService.findById(event.businessId);
  
  if (event.result === 'APPROVED') {
    // 审批通过，开始分配
    await ticketService.update(ticket.id, { status: 'OPEN' });
    await assignService.autoAssign(ticket);
  } else {
    // 审批拒绝
    await ticketService.update(ticket.id, {
      status: 'CANCELLED',
      resolution: `审批被拒绝: ${event.comment}`,
    });
  }
}
```

### 与组织架构集成

```typescript
// 获取用户信息
async function enrichTicketWithUserInfo(ticket: Ticket): Promise<TicketWithUser> {
  const [creator, assignee] = await Promise.all([
    organizationService.getUserById(ticket.creatorId),
    ticket.assigneeId ? organizationService.getUserById(ticket.assigneeId) : null,
  ]);
  
  return {
    ...ticket,
    creator: {
      id: creator.id,
      name: creator.name,
      email: creator.email,
      department: creator.department,
    },
    assignee: assignee ? {
      id: assignee.id,
      name: assignee.name,
      email: assignee.email,
    } : null,
  };
}

// 获取用户上级（用于升级）
async function getEscalationTarget(userId: string): Promise<string | null> {
  const user = await organizationService.getUserById(userId);
  if (user.managerId) {
    return user.managerId;
  }
  
  // 如果没有直属上级，找部门负责人
  const department = await organizationService.getDepartmentById(user.departmentId);
  return department?.headId || null;
}
```

---

## 📊 性能优化

### 数据库索引

```sql
-- 主要查询索引
CREATE INDEX idx_tickets_status ON tickets(status);
CREATE INDEX idx_tickets_category_id ON tickets(category_id);
CREATE INDEX idx_tickets_creator_id ON tickets(creator_id);
CREATE INDEX idx_tickets_assignee_id ON tickets(assignee_id);
CREATE INDEX idx_tickets_assignee_group_id ON tickets(assignee_group_id);
CREATE INDEX idx_tickets_created_at ON tickets(created_at DESC);
CREATE INDEX idx_tickets_region ON tickets(region);

-- 复合索引（常用查询）
CREATE INDEX idx_tickets_status_assignee ON tickets(status, assignee_id);
CREATE INDEX idx_tickets_status_group ON tickets(status, assignee_group_id);
CREATE INDEX idx_tickets_region_status ON tickets(region, status);

-- 评论索引
CREATE INDEX idx_ticket_comments_ticket_id ON ticket_comments(ticket_id);
CREATE INDEX idx_ticket_comments_created_at ON ticket_comments(created_at DESC);

-- 活动日志索引
CREATE INDEX idx_ticket_activities_ticket_id ON ticket_activities(ticket_id);
CREATE INDEX idx_ticket_activities_created_at ON ticket_activities(created_at DESC);
```

### 缓存策略

```typescript
// Redis 缓存 key 设计
const CACHE_KEYS = {
  // 工单计数器
  ticketCounter: (date: string) => `ticket:counter:${date}`,
  
  // 分类列表缓存
  categories: () => 'ticket:categories',
  
  // 处理组缓存
  assignmentGroup: (id: string) => `ticket:group:${id}`,
  
  // 用户工单统计
  userTicketStats: (userId: string) => `ticket:stats:user:${userId}`,
  
  // 分配状态（轮询）
  assignmentRoundRobin: (groupId: string) => `ticket:assign:rr:${groupId}`,
  
  // 工作负载
  userWorkload: (userId: string) => `ticket:workload:${userId}`,
};

// 缓存策略
const CACHE_TTL = {
  categories: 3600,      // 1 小时
  assignmentGroup: 1800, // 30 分钟
  userStats: 300,        // 5 分钟
  workload: 60,          // 1 分钟
};
```

### 查询优化

```typescript
// 分页查询优化
async function findTicketsOptimized(
  query: QueryTicketDto,
  context: TicketQueryContext
): Promise<PaginatedResult<Ticket>> {
  // 使用 cursor-based pagination 处理大数据量
  if (query.cursor) {
    return this.findWithCursor(query, context);
  }
  
  // 限制最大页码
  const page = Math.min(query.page || 1, 100);
  const pageSize = Math.min(query.pageSize || 20, 50);
  
  // 并行查询数据和总数
  const [tickets, total] = await Promise.all([
    this.prisma.ticket.findMany({
      where: this.buildWhere(query, context),
      orderBy: this.buildOrderBy(query),
      skip: (page - 1) * pageSize,
      take: pageSize,
      include: {
        category: { select: { id: true, name: true, code: true } },
      },
    }),
    this.prisma.ticket.count({
      where: this.buildWhere(query, context),
    }),
  ]);
  
  return { data: tickets, total, page, pageSize };
}
```

---

## 🔒 安全设计

### 权限控制

```typescript
// 权限点定义
const TICKET_PERMISSIONS = {
  // 基础权限
  CREATE: 'ticket:create',
  VIEW_OWN: 'ticket:view:own',
  VIEW_ASSIGNED: 'ticket:view:assigned',
  VIEW_GROUP: 'ticket:view:group',
  VIEW_ALL: 'ticket:view:all',
  
  // 处理权限
  PROCESS: 'ticket:process',
  ASSIGN: 'ticket:assign',
  REASSIGN: 'ticket:reassign',
  
  // 管理权限
  MANAGE_CATEGORY: 'ticket:category:manage',
  MANAGE_GROUP: 'ticket:group:manage',
  MANAGE_SLA: 'ticket:sla:manage',
  
  // 超级权限
  ADMIN: 'ticket:admin',
};

// 权限守卫
@Injectable()
class TicketPermissionGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const ticketId = request.params.id;
    
    // 管理员直接通过
    if (user.permissions.includes(TICKET_PERMISSIONS.ADMIN)) {
      return true;
    }
    
    const ticket = await this.ticketService.findById(ticketId);
    
    // 检查是否有权限访问
    return this.hasAccessToTicket(user, ticket);
  }
  
  private hasAccessToTicket(user: User, ticket: Ticket): boolean {
    // 创建者
    if (ticket.creatorId === user.id) return true;
    // 处理人
    if (ticket.assigneeId === user.id) return true;
    // 关注者
    if (ticket.watcherIds.includes(user.id)) return true;
    // 同组成员
    if (user.groupIds.includes(ticket.assigneeGroupId)) return true;
    // 区域管理员
    if (this.isRegionAdmin(user, ticket.region)) return true;
    
    return false;
  }
}
```

### 数据脱敏

```typescript
// 内部评论对普通用户不可见
function sanitizeCommentsForUser(
  comments: TicketComment[],
  userId: string,
  isAdmin: boolean
): TicketComment[] {
  if (isAdmin) return comments;
  
  return comments.filter(c => !c.isInternal);
}

// 敏感字段脱敏
function sanitizeTicketForResponse(
  ticket: Ticket,
  context: RequestContext
): SafeTicket {
  const safe = { ...ticket };
  
  // 非管理员隐藏某些字段
  if (!context.isAdmin) {
    delete safe.internalNotes;
  }
  
  return safe;
}
```

---

## 📚 相关文档

- [PRD.md](./PRD.md) - 产品需求文档
- [API.md](./API.md) - API 接口文档
- [TODO.md](./TODO.md) - 开发待办事项

---

**创建日期**: 2025-12-11  
**最后更新**: 2025-12-11  
**版本**: v1.3
