- monorepo: pnpm workspace + go.work + pyproject.toml + commitlint/husky - infra: docker-compose (minimal + full profiles) + init-sql + prometheus - arch-scan: multi-language scanner skeleton (TS/Go/Python/Proto) - shared-proto: buf v2 + classes.proto (ClassService CRUD contract) - api-gateway: Go/Gin + JWT HS256 auth + reverse proxy + request ID - classes: NestJS golden template (error system + observability + middleware + CRUD + tests) - teacher-portal: Next.js + paper-feel UI design system - CI/CD: 4 workflows (go/ts/py/proto) - docs: migration guide + project_rules + coding-standards + git-workflow + ui-design-system + 004 + 9 module READMEs + known-issues + spec/plan migration + roadmap
353 lines
12 KiB
Markdown
353 lines
12 KiB
Markdown
# 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 事务,简化一致性保证
|