# classes 班级服务（P1 黄金模板）

> 版本：1.0
> 日期：2026-07-07
> 状态：P1 已交付（P3 合并入 core-edu）
> 关联文档：[架构影响地图](../../architecture/004_architecture_impact_map.md)

---

## 1. 模块职责

classes 服务是 P1 阶段的**黄金模板服务**，承担以下双重职责：

### 1.1 业务职责
- **班级 CRUD**：创建、查询、更新、删除班级（Class 聚合根）
- **班级成员管理**：学生加入/退出班级、教师分配
- **班级查询**：按年级/学校/教师维度查询班级列表

### 1.2 模板职责（供后续服务复制）
- **横切关注点验证**：JWT 鉴权、权限校验、DataScope 过滤、OpenTelemetry 追踪、结构化日志、错误处理、参数校验、限流降级
- **CQRS 模式验证**：Command 写 MySQL + Outbox，Query 读 Redis 缓存
- **契约验证**：protobuf 定义 + buf 生成 + gRPC + Connect-RPC
- **测试范式验证**：单元测试（vitest）+ 集成测试 + 契约测试 + E2E 测试

**P3 阶段**：classes 域将合并入 core-edu 服务，作为 core-edu 的一个聚合模块。

---

## 2. 技术栈

| 类别 | 技术 | 版本 | 用途 |
|------|------|------|------|
| 语言 | TypeScript | 5.5+ | 类型安全 |
| 框架 | NestJS | 10.x | 依赖注入、模块化、装饰器 |
| ORM | Drizzle ORM | 0.31+ | 类型安全 SQL 构建器 |
| 数据库 | MySQL | 8.x | 写模型主库 |
| 缓存 | Redis | 7.x | 读模型缓存、会话 |
| 校验 | Zod | 3.23+ | 运行时类型校验 |
| 链路追踪 | OpenTelemetry | 1.24+ | 分布式追踪 |
| 日志 | pino | 9.x | 结构化高性能日志 |
| 测试 | vitest | 2.x | 单元/集成测试 |
| 契约测试 | buf | 1.40+ | protobuf 契约与生成 |
| E2E | Playwright | 1.45+ | 端到端测试 |
| 容器化 | Docker | - | 多阶段构建 |

---

## 3. 架构图

```mermaid
graph TB
    subgraph Client["调用方"]
        BFF[BFF 聚合层]
    end

    subgraph Classes["classes 服务"]
        subgraph API["API 层"]
            CTRL[ClassController<br/>gRPC + Connect-RPC]
            GUARD[AuthGuard<br/>JWT metadata 校验]
            PERM[PermissionGuard<br/>requirePermission]
            SCOPE[DataScopeInterceptor<br/>6 级数据范围]
        end

        subgraph App["Application 层"]
            CMD[CommandHandler<br/>CreateClass/UpdateClass/DeleteClass]
            QRY[QueryHandler<br/>ListClasses/GetClass]
            OUTBOX[OutboxWriter<br/>同事务写 outbox]
        end

        subgraph Domain["Domain 层"]
            AGG[ClassAggregate<br/>聚合根 + 不变量]
            EVT[DomainEvent<br/>ClassCreated/Updated/Deleted]
        end

        subgraph Infra["Infrastructure 层"]
            REPO[ClassRepository<br/>Drizzle ORM]
            CACHE[ClassCache<br/>Redis 读模型]
            PROJ[Projection<br/>outbox → Redis]
        end
    end

    subgraph Store["存储"]
        MYSQL[(MySQL<br/>classes + outbox)]
        REDIS[(Redis<br/>班级缓存)]
        KAFKA[(Kafka<br/>领域事件)]
    end

    BFF -->|gRPC| GUARD
    GUARD --> PERM
    PERM --> SCOPE
    SCOPE --> CTRL
    CTRL --> CMD
    CTRL --> QRY
    CMD --> AGG
    AGG --> EVT
    CMD --> OUTBOX
    CMD --> REPO
    QRY --> CACHE
    REPO --> MYSQL
    OUTBOX --> MYSQL
    CACHE --> REDIS
    PROJ --> MYSQL
    PROJ --> REDIS
    PROJ -->|Relay Worker| KAFKA
```

---

## 4. 核心流程图

### 4.1 CreateClass 命令流程（写路径）

```mermaid
sequenceDiagram
    participant BFF as BFF
    participant CTRL as ClassController
    participant CMD as CreateClassHandler
    participant AGG as ClassAggregate
    participant REPO as ClassRepository
    participant OUTBOX as OutboxWriter
    participant DB as MySQL
    participant PROJ as Projection
    participant K as Kafka

    BFF->>CTRL: gRPC CreateClass(req)
    CTRL->>CTRL: AuthGuard 校验 JWT metadata
    CTRL->>CTRL: PermissionGuard 校验 class:create
    CTRL->>CTRL: DataScopeInterceptor 注入 schoolId
    CTRL->>CMD: execute(CreateClassCommand)
    CMD->>AGG: ClassAggregate.create(name, gradeId, schoolId)
    AGG->>AGG: 校验不变量（名称非空、年级存在）
    AGG-->>CMD: ClassCreatedEvent
    CMD->>REPO: save(classAggregate)
    REPO->>DB: INSERT INTO classes ...
    CMD->>OUTBOX: append(ClassCreatedEvent)
    OUTBOX->>DB: INSERT INTO outbox (同事务)
    DB-->>CMD: 事务提交成功
    CMD-->>CTRL: ClassDTO
    CTRL-->>BFF: gRPC 响应

    Note over PROJ,K: 异步投影
    PROJ->>DB: 轮询 outbox (100ms)
    PROJ->>K: 发布 edu.org.class.created
    PROJ->>DB: 标记 outbox processed
```

### 4.2 ListClasses 查询流程（读路径）

```mermaid
sequenceDiagram
    participant BFF as BFF
    participant CTRL as ClassController
    participant QRY as ListClassesHandler
    participant CACHE as ClassCache
    participant REPO as ClassRepository
    participant DB as MySQL

    BFF->>CTRL: gRPC ListClasses(filter)
    CTRL->>CTRL: AuthGuard + PermissionGuard(class:read)
    CTRL->>CTRL: DataScopeInterceptor 注入 dataScope=L2/gradeId
    CTRL->>QRY: execute(ListClassesQuery)
    QRY->>CACHE: getClassesByGrade(gradeId)
    alt 缓存命中
        CACHE-->>QRY: ClassDTO[]
    else 缓存未命中
        CACHE-->>QRY: null
        QRY->>REPO: findByGrade(gradeId, dataScope)
        REPO->>DB: SELECT * FROM classes WHERE grade_id=? AND ...
        DB-->>REPO: 行数据
        REPO-->>QRY: ClassAggregate[]
        QRY->>CACHE: setClassesByGrade(gradeId, dtos, TTL=5min)
    end
    QRY-->>CTRL: ClassDTO[]
    CTRL-->>BFF: gRPC 响应
```

### 4.3 事件发布与投影流程

```mermaid
flowchart TD
    CMD[Command 处理完成] --> TX[MySQL 事务提交<br/>classes 表 + outbox 表]
    TX --> SUCC{提交成功?}
    SUCC -- 是 --> RELAY[Relay Worker 轮询<br/>每 100ms]
    SUCC -- 否 --> ROLL[回滚<br/>无事件产生]
    RELAY --> READ[读取 outbox 未处理记录]
    READ --> PUB[发布到 Kafka<br/>edu.org.class.created]
    PUB --> MARK[标记 outbox processed=true]
    MARK --> CONSUME[消费者处理]
    CONSUME --> PROJ1[Projection 更新<br/>Redis 读模型]
    CONSUME --> PROJ2[DataAna 投影<br/>ClickHouse 宽表]
    CONSUME --> PROJ3[Msg 服务<br/>通知触发]
```

---

## 5. 对外契约

### 5.1 gRPC 服务契约（protobuf）

```protobuf
service ClassService {
  rpc CreateClass(CreateClassRequest) returns (Class);
  rpc GetClass(GetClassRequest) returns (Class);
  rpc ListClasses(ListClassesRequest) returns (ListClassesResponse);
  rpc UpdateClass(UpdateClassRequest) returns (Class);
  rpc DeleteClass(DeleteClassRequest) returns (Empty);
}

message Class {
  string id = 1;
  string name = 2;
  string grade_id = 3;
  string school_id = 4;
  int32 student_count = 5;
  string homeroom_teacher_id = 6;
  int64 created_at = 7;
  int64 updated_at = 8;
}
```

### 5.2 领域事件契约

| 事件 | Topic | 生产者 | 消费者 | 字段 |
|------|-------|--------|--------|------|
| ClassCreated | `edu.org.class.created` | classes | DataAna、Msg | classId, name, gradeId, schoolId |
| ClassUpdated | `edu.org.class.updated` | classes | DataAna、Msg | classId, changes, updatedAt |
| ClassDeleted | `edu.org.class.deleted` | classes | DataAna、Msg | classId, deletedAt |

### 5.3 数据库表

| 表 | 用途 | 关键字段 |
|----|------|----------|
| classes | 班级写模型主表 | id, name, grade_id, school_id, homeroom_teacher_id, student_count, created_at, updated_at |
| outbox | 事件 Outbox 表 | id, aggregate_id, event_type, payload, processed, created_at |

---

## 6. 依赖关系

### 6.1 上游依赖（调用方）

| 上游 | 协议 | 说明 |
|------|------|------|
| teacher-bff | gRPC | 教师端班级管理 |
| student-bff | gRPC | 学生端班级查询 |
| parent-bff | gRPC | 家长端子女班级查询 |

### 6.2 下游依赖（被调用方）

| 下游 | 协议 | 说明 |
|------|------|------|
| MySQL | TCP | 写模型主库（classes + outbox） |
| Redis | TCP | 读模型缓存（5 分钟 TTL） |
| Kafka | TCP | 领域事件发布 |

### 6.3 共享包依赖

| 包 | 用途 |
|----|------|
| shared-proto | ClassService protobuf 定义与生成代码 |
| shared-ts | 通用类型、错误码、日志字段约定 |

---

## 7. 架构约束

1. **黄金模板强制**：本服务的横切关注点实现必须可复制，后续服务（iam/core-edu/content 等）以本服务为模板
2. **CQRS 强制**：写路径必须经 Command → Aggregate → Repository + Outbox；读路径必须先查缓存再查库
3. **Outbox 强制**：所有写操作必须同事务写入 outbox 表，禁止直接发布 Kafka 消息
4. **权限校验强制**：每个 RPC 方法必须经 PermissionGuard 校验 `class:create`/`class:read`/`class:update`/`class:delete`
5. **DataScope 强制**：查询必须经 DataScopeInterceptor 注入数据范围，禁止全表查询
6. **不变量保护**：ClassAggregate 必须在创建/更新时校验业务不变量（名称非空、学生数 ≤ 上限）
7. **事务边界**：单聚合事务，禁止跨聚合事务
8. **P3 合并约束**：合并入 core-edu 时，protobuf 契约保持不变，仅内部模块迁移

---

## 8. 架构决策

### ADR-001：选用 NestJS 而非 Express/Fastify

**决策**：业务服务统一使用 NestJS 10 框架。

**理由**：
- 依赖注入容器便于横切关注点组合（Guard/Interceptor/Pipe）
- 模块化系统天然匹配 DDD 限界上下文
- 装饰器声明式编程，代码可读性高
- 原生支持 gRPC、Microservices、OpenTelemetry

**替代方案**：Express + 手工组合中间件 - 缺乏依赖注入，横切关注点难以复用。

### ADR-002：选用 Drizzle ORM 而非 Prisma/TypeORM

**决策**：数据访问层使用 Drizzle ORM。

**理由**：
- SQL-first 设计，生成的 SQL 可预测、可优化
- 类型安全且零运行时开销（不像 Prisma 有查询引擎）
- 支持 MySQL 事务、Outbox 模式所需的同事务多表写入
- Schema 即代码，迁移文件可读性强

**替代方案**：Prisma - 查询引擎二进制大、事务控制弱；TypeORM - 装饰器模型过重。

### ADR-003：CQRS 读写分离

**决策**：写路径操作 MySQL 主库，读路径优先读 Redis 缓存。

**理由**：
- 班级列表读多写少，缓存可承接 90%+ 读流量
- 写路径与读路径分离，可独立扩展
- Outbox 保证缓存最终一致（事件触发缓存失效）

**替代方案**：直接读 MySQL - 高并发下主库压力大；双写 - 一致性难保证。

### ADR-004：Outbox 模式保证事件可靠发布

**决策**：所有领域事件通过 Outbox 表同事务写入，Relay Worker 异步发布。

**理由**：
- 业务表与 outbox 表同事务，保证"要么都成功要么都失败"
- Relay Worker 轮询 outbox 发布到 Kafka，至少一次语义
- 避免业务提交成功但事件丢失的问题

**替代方案**：直接发 Kafka - 事务提交失败但消息已发，导致幻读；CDC - 增加运维复杂度，P4 引入。

### ADR-005：Zod 运行时校验

**决策**：所有 RPC 请求参数使用 Zod schema 校验。

**理由**：
- protobuf 提供编译期类型检查，但运行时仍需业务规则校验
- Zod schema 可复用为 TypeScript 类型，单一数据源
- NestJS Pipe 集成，校验失败返回结构化错误

### ADR-006：pino 结构化日志

**决策**：使用 pino 而非 winston 或 NestJS 默认 Logger。

**理由**：
- pino 性能是 winston 的 3-5 倍，异步写入不阻塞事件循环
- JSON 结构化输出，便于 Loki 采集与查询
- 支持 traceId/userId 上下文注入，与 OpenTelemetry 对齐

### ADR-007：P3 阶段合并入 core-edu

**决策**：classes 服务在 P3 阶段合并入 core-edu 服务，作为 core-edu 的一个模块。

**理由**：
- classes 域与课程、作业、考试域强耦合，跨服务调用频繁
- P1 独立服务用于验证黄金模板，P3 合并以减少网络开销
- 合并后 protobuf 契约保持不变，仅内部代码迁移
- 合并后 outbox 共享 core-edu 事务，简化一致性保证
