Files
Edu/docs/superpowers/specs/2026-07-14-architecture-v2-redesign-design.md
SpecialX 62682b9d61 docs(docs): 004 arch.db 二次校验 + 新增 v2 架构重设计 spec
004 修正 7 处与代码不符描述:

- proto 统计 / 包名 / core-edu gRPC service 数

- msg RPC 数 / data-ana RPC 数

- student-bff 模块数 / parent-bff 模块数

新增 v2 架构重设计 spec(996 行):

- Apollo Federation BFF 联邦

- DataScope @requires 运行时解析

- iam 拆分 config-service

- content CQRS / ai 无状态化

- SSE 优先 / Temporal 不引入

Spec 自审修复 6 处问题:

- apollo-router 端口冲突 4000→4011

- Kafka topic 命名一致性

- CDC/Outbox 投影器职责分工

- 改动点数字 / 服务数 / 容器数计算
2026-07-14 21:25:37 +08:00

1020 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Edu 平台架构 v2 重设计
> 版本v2.0
> 日期2026-07-14
> 状态:待评审
> 关联:
>
> - [004 架构影响地图](../../architecture/004_architecture_impact_map.md) v2.0(当前态)
> - [Portal Shell 插件化仪表盘设计](./2026-07-14-portal-shell-widget-dashboard-design.md) v2.1
> - [0010 架构蓝图](../../architecture/0010_architecture.md)
> - [项目规则](../../.trae/rules/project_rules.md)
---
## 1. 背景与目标
### 1.1 v1 架构痛点诊断
当前 Edu 微服务架构v1/v2.0 of 004存在以下核心痛点
1. **BFF 样板代码灾难**3 个手写 BFFteacher-bff / student-bff / parent-bff重复实现 GraphQL Resolver + gRPC 调用,每个字段改动需改 3 处proto → schema → resolver×3 灾难
2. **DataScope 跨库 JOIN 悖论**core-edu 要过滤"管理员可见班级",但用户-学校关系在 iam 库,微服务禁止跨库 JOIN运行时传 ID 列表性能崩溃
3. **CDC 与 Outbox 文档矛盾**§1.1a 画 CDCMySQL→Debezium→Kafka§7.1 画 Outbox业务表→Relay Worker→Kafka看起来像两条路径做同一件事
4. **WebSocket 性能开销**push-gateway 同时支持 WS/SSE但 K12 场景以单向通知为主WS 心跳/握手开销过大
5. **Temporal 滥用边界**:规划引入 Temporal 但未明确边界,短事务若走 Temporal 会被入库开销拖垮吞吐
6. **iam 职责过载**iam 承载认证 + RBAC + 审计 + JWKS + 插件配置6 张表)+ DataScope成为"上帝服务"
7. **content 多存储耦合**content 同时写 MySQL + Neo4j + ES事务一致性、同步延迟、故障域都是问题
8. **ai 有状态化**ai 服务内嵌 WorkflowStateStore边界不清难以水平扩展
9. **配置散落**:配置散落在 env / DB / code无统一配置中心
10. **前端 4 portal 重复**4 个独立 Next.js portal 重复实现 Dockerfile / i18n / auth / layout已在 portal-shell v2.1 设计稿中解决)
### 1.2 v2 设计目标
1. **BFF 联邦化**:采用 Apollo Federation消灭手写 BFF 样板代码
2. **DataScope 运行时解析**:通过 @requires 指令在子图间传递可见范围,解决跨库悖论
3. **CDC/Outbox 职责分工**Outbox 负责领域事件CDC 负责读模型投影
4. **SSE 优先**:推送默认走 SSEWS 仅限强双向场景
5. **Temporal 不引入**:长工作流用 Redis 状态机 + Outbox 事件
6. **服务边界清晰**iam 拆分、content CQRS、ai 无状态化
7. **配置中心化**config-service 统一管理动态配置
8. **单 portal-shell**:前端单容器部署(引用 portal-shell v2.1 设计稿)
### 1.3 约束条件
- **多 AI 协作模式不变**coord + dev + sre模块单一负责制
- **技术栈全开放**:可换 DB / MQ / 框架 / 语言
- **数据可清零**:无迁移包袱,允许重建测试数据
- **微服务架构保留**:不为当前规模妥协,为未来扩展性预留
- **AI 禁止切换分支**:分支创建/切换由人类决策者负责
### 1.4 非目标
- 不重写已有业务逻辑(仅调整架构边界)
- 不替换 MySQL / Redis / Kafka / ClickHouse / Neo4j / Elasticsearch
- 不引入 etcd / ConsulK12 场景过重)
- 不引入 Temporal / Cadence短事务禁用长工作流用轻量状态机
---
## 2. 整体架构
### 2.1 6 层分层架构
```
L1 用户层
└─ 教师 / 学生 / 家长 / 管理员
L2 微前端层(单容器)
└─ portal-shellModular Monolith + Micro-kernelNext.js 15 App Router
· RSC 服务端预取 Config + initialData
· dynamic import 懒加载插件
· URL Params + Zustand 状态共享
· SWR 静默刷新配置
· 详见 portal-shell v2.1 设计稿
L3 边缘网关层Go 1.25 / Gin
├─ api-gatewayJWT 校验 + 限流 + 熔断 + CORS
└─ realtime-gatewaySSE 优先 + WS 仅监考 + Redis Pub/Sub
L4 GraphQL 联邦层Apollo Router
└─ apollo-router自动查询计划 + @requires DataScope 传递)
· 替代 3 个 BFF 的聚合职责
· 子图自主暴露 GraphQL
L5 业务服务层NestJS / FastAPI按 DDD 限界上下文)
├─ iam认证 + RBAC + JWKS + DataScope— 拆分后
├─ config-service插件配置 + 布局 + 用户偏好)— 新拆分
├─ core-edu教学核心 + 教学组织)
├─ content内容资源CQRSMySQL 写 + Neo4j/ES 读投影)
├─ msg消息通知
├─ data-ana数据分析有状态
└─ aiLLM 推理,无状态)
L6 数据层
├─ MySQL 8.0(写模型,每服务独占 schema
├─ Redis 7缓存 + 会话 + Pub/Sub + 限流 + ai workflow 状态)
├─ ClickHouse 24.3(读模型宽表)
├─ Neo4j 5.20(知识图谱读模型)
├─ Elasticsearch 8.13(题库检索读模型)
└─ 配置中心config-service + Redis 缓存)
```
### 2.2 服务清单v2
| 类别 | 服务名 | 语言/框架 | HTTP | gRPC | GraphQL | 限界上下文 | 变更说明 |
| ------ | ---------------- | -------------------- | ---- | ----- | ------- | ------------------------ | -------------------------------------------------------- |
| 边缘 | api-gateway | Go 1.25 / Gin | 8080 | — | — | 网关 | 保留,移除 BFF 代理路由 |
| 边缘 | realtime-gateway | Go 1.25 / Gin | 8081 | — | — | 推送 | 改名SSE 优先 |
| 联邦 | apollo-router | Rust (Apollo Router) | 4011 | — | ✅ | GraphQL 联邦 | 新增(避开 portal 4000-4009 段位,与 v1 并行运行不冲突) |
| 业务 | iam | NestJS | 3002 | 50052 | ✅ 子图 | 认证+RBAC+JWKS+DataScope | 拆分,移除插件配置 |
| 业务 | config-service | NestJS | 3011 | 50059 | ✅ 子图 | 插件+布局+偏好 | 新增,从 iam 拆出 |
| 业务 | core-edu | NestJS | 3004 | 50053 | ✅ 子图 | 教学核心+组织 | 保留 |
| 业务 | content | NestJS | 3005 | 50054 | ✅ 子图 | 内容资源 | CQRS 改造 |
| 业务 | msg | NestJS | 3007 | 50056 | ✅ 子图 | 沟通通知 | 保留 |
| 业务 | data-ana | FastAPI | 3006 | 50055 | ✅ 子图 | 数据分析 | 保留 |
| 业务 | ai | FastAPI | 3008 | 50058 | ✅ 子图 | LLM 推理 | 无状态化 |
| 微前端 | portal-shell | Next.js 15 | 4010 | — | — | 教师端 Shell | 新增,替代 4 portal |
**移除的服务**
- teacher-bff / student-bff / parent-bff → 由 apollo-router 替代
- teacher-portal / student-portal / parent-portal / admin-portal → 由 portal-shell 替代
**移除的组件**
- Debezium Connect → v2 默认不启用,投影器走 Outbox 事件(见 §5.4 / §6.1);仅当 Outbox 事件无法覆盖投影需求时,作为备选方案启用
- Temporal → 不引入ai 保留 WorkflowStateStore 作为轻量替代)
### 2.3 核心数据流
```mermaid
graph TB
User[用户] --> PortalShell[portal-shell :4010<br/>RSC 预取]
PortalShell --> ApiGateway[api-gateway :8080<br/>JWT + 限流]
ApiGateway --> ApolloRouter[apollo-router :4011<br/>GraphQL 联邦]
ApolloRouter -->|子图查询| Iam[iam 子图]
ApolloRouter -->|子图查询| Config[config-service 子图]
ApolloRouter -->|子图查询| CoreEdu[core-edu 子图]
ApolloRouter -->|子图查询| Content[content 子图]
ApolloRouter -->|子图查询| Msg[msg 子图]
ApolloRouter -->|子图查询| DataAna[data-ana 子图]
ApolloRouter -->|子图查询| Ai[ai 子图]
ApolloRouter -.@requires DataScope.-> Iam
CoreEdu -.接收 visibleClassIds.-> CoreEdu
PortalShell -.SSE 推送.-> RealtimeGw[realtime-gateway :8081]
RealtimeGw -.Kafka 消费.-> Msg
Iam <-->|Outbox| Kafka[(Kafka)]
CoreEdu <-->|Outbox| Kafka
Content <-->|Outbox + 投影器消费| Kafka
Msg <-->|Outbox| Kafka
DataAna <-->|Outbox 消费| Kafka
Ai <-->|Outbox| Kafka
Iam --> MySQL[(MySQL)]
Config --> MySQL
CoreEdu --> MySQL
Content --> MySQL
Content -.Outbox 投影器.-> Neo4j[(Neo4j)]
Content -.Outbox 投影器.-> ES[(Elasticsearch)]
Msg --> MySQL
DataAna --> ClickHouse[(ClickHouse)]
```
### 2.4 关键架构决策摘要
| 决策点 | v1 方案 | v2 方案 | 理由 |
| ------------ | ------------------- | ------------------------------- | ---------------------------- |
| 前端 | 4 portal + MF | 单 portal-shell | Modular Monolith减少重复 |
| BFF | 3 个手写 BFF | Apollo Federation | 工业标准,零样板代码 |
| DataScope | 网关透传 + 服务注入 | @requires 运行时解析 | 解决跨库悖论 |
| Push | WS + SSE | SSE 优先 + WS 仅监考 | K12 场景单向为主 |
| Temporal | 规划引入 | 不引入,保留 WorkflowStateStore | 短事务禁用,长工作流轻量替代 |
| iam 职责 | 认证+RBAC+插件配置 | 拆分 iam + config-service | 避免上帝服务 |
| content 存储 | MySQL+Neo4j+ES 混写 | MySQL 写 + Outbox 投影器读模型 | CQRS 分离 |
| ai 状态 | 有状态工作流 | 无状态推理 + WorkflowStateStore | 明确边界 |
| 配置 | env + DB 散落 | config-service 统一 | 配置中心化 |
---
## 3. Apollo Federation BFF 层
### 3.1 设计原理
**痛点**v1 的 3 个手写 BFF 重复实现 GraphQL Resolver + gRPC 调用,每个字段改动需改 3 处proto → schema → resolver3 个 BFF = ×3 灾难。
**Apollo Federation 工业标准方案**
- 每个业务服务自主暴露 GraphQL 子图Subgraph
- Apollo Router 作为唯一入口自动拆分查询计划Query Plan
- 客户端发一个 QueryRouter 自动决定调用哪些子图、以什么顺序、如何聚合
- 零手写聚合代码
### 3.2 子图暴露方式proto → GraphQL 自动生成
从 proto 自动生成 GraphQL schema业务服务只需实现 Resolver。
**proto 扩展**
```protobuf
// core_edu.proto
service ExamService {
rpc GetExam(GetExamRequest) returns (Exam) {
option (graphql.query) = "exam";
}
rpc ListExams(ListExamsRequest) returns (ListExamsResponse) {
option (graphql.query) = "exams";
}
}
message Exam {
string exam_id = 1;
string title = 2;
repeated string class_ids = 3;
}
```
**生成的 GraphQL 子图**core-edu 子图):
```graphql
type Query {
exam(examId: ID!): Exam
exams(classIds: [ID!]!): [Exam!]!
}
type Exam @key(fields: "examId") {
examId: ID!
title: String
classIds: [String!]!
}
```
### 3.3 各服务子图清单
| 服务 | 子图关键类型 | DataScope 依赖 |
| -------------- | ----------------------------------------------------------- | --------------------------------------- |
| iam | User / Role / Permission / School | 无(权限源) |
| config-service | PluginConfig / LayoutTemplate / UserOverride | 无 |
| core-edu | Exam / Homework / Grade / Attendance / Class / Schedule | `visibleClassIds` / `visibleStudentIds` |
| content | Textbook / Chapter / Question / KnowledgePoint / LessonPlan | `editableSubjectIds` |
| msg | Notification / Template / Preference | `visibleNotificationScopes` |
| data-ana | Analytics / Dashboard / Mastery / Warning | `visibleClassIds` / `visibleStudentIds` |
| ai | Chat / Question / Expression / LessonPlan / Report | `userId`(个人级) |
### 3.4 Apollo Router 部署架构
```
┌─────────────────────────────────────────────────┐
│ api-gateway :8080Go / Gin
│ · JWT RS256 校验 │
│ · 限流 / 熔断 / CORS │
│ · 注入 x-user-id / x-user-role / x-dataScope │
└────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ apollo-router :4011Rust官方二进制
│ · 接收 GraphQL Query │
│ · 解析查询计划Query Plan
│ · 并发调用子图,@requires 传递 DataScope │
│ · 聚合响应返回客户端 │
│ · 内置缓存 / 持续查询 / 遥测 │
└────────────────┬────────────────────────────────┘
┌─────────┼─────────┬─────────┬─────────┐
▼ ▼ ▼ ▼ ▼
iam:3002 core-edu content msg data-ana
/graphql :3004 :3005 :3007 :3006
/graphql /graphql /graphql /graphql
```
**Router 配置**`router.yaml`
```yaml
supergraph:
listen: 0.0.0.0:4011
path: /graphql
introspection: true
homepage:
enabled: true
health_check:
listen: 0.0.0.0:8088
override_subgraph_url:
iam: http://iam:3002/graphql
config-service: http://config-service:3011/graphql
core-edu: http://core-edu:3004/graphql
content: http://content:3005/graphql
msg: http://msg:3007/graphql
data-ana: http://data-ana:3006/graphql
ai: http://ai:3008/graphql
headers:
all:
request:
- propagate:
named: "authorization"
- propagate:
named: "x-user-id"
- propagate:
named: "x-user-role"
- propagate:
named: "x-dataScope"
- propagate:
named: "x-request-id"
```
### 3.5 BFF 样板代码消灭对比
**v1 流程(新增字段 `exam.duration`,手动改动点 6 处)**
1.`core_edu.proto`,加 `int32 duration = 5;`(手动)
2. 重新生成 proto 代码(自动)
3. 改 teacher-bff 的 `schema.graphql` + resolver手动
4. 改 student-bff 的 `schema.graphql` + resolver手动
5. 改 parent-bff 的 `schema.graphql` + resolver手动
6. 改 portal 的 GraphQL 查询(手动)
**v2 流程(手动改动点 2 处)**
1.`core_edu.proto`,加 `int32 duration = 5;`(手动)
2. 运行 `buf generate`,自动生成 GraphQL schema自动
3. core-edu 服务自动暴露 `duration` 字段(自动)
4. Apollo Router 自动拉取新 schema自动
5. 改 portal-shell 的 GraphQL 查询(手动)
**手动改动点从 6 处 → 2 处**(仅改 proto + 改前端查询)。
---
## 4. 业务服务层调整
### 4.1 iam 拆分iam + config-service
**问题**iam 承载认证 + RBAC + 审计 + JWKS + 插件配置v2.1 新增 6 张表)+ DataScope 解析,成为"上帝服务"。
**拆分方案**
| 服务 | 职责 | 表 | gRPC Service |
| ---------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| iam瘦身 | 认证 + RBAC + JWKS + 审计 + DataScope | users / roles / permissions / refresh_tokens / sessions / totp / audit_logs / user_school_role | IamService / RbacService / JwksService / AuditService |
| config-service新增 | 插件配置 + 布局配置 + 用户偏好 | plugin_registry / role_plugin_mapping / role_layout_default / layout_templates / user_layout_override / plugin_packages | ConfigService |
**拆分理由**
- 认证是"高频低变"(每次请求校验 JWT
- 配置是"低频高变"admin 改配置、用户改偏好)
- 两者耦合会导致:配置表锁竞争影响认证、配置 Schema 变更需重启认证服务
**DataScope 归属**
- iam 保留 `user_school_role` 表(用户-学校-角色关系)
- iam 子图暴露 `visibleClassIds` / `visibleStudentIds` 查询DataScope 解析在 iam
- config-service 不涉及 DataScope
**端口分配**
- iam: 3002 / gRPC 50052不变
- config-service: 3011 / gRPC 50059新增
### 4.2 content CQRS 改造
**问题**content 当前同时写 MySQL + Neo4j + ES三存储事务一致性、同步延迟、故障域都是问题。
**CQRS 改造方案**
```
写侧Command
content 服务 → MySQL唯一写模型
↓ Outbox 事件
Kafkaedu.content.* topic
读侧投影器Query
├── neo4j-projector消费事件 → 写 Neo4j 知识图谱)
├── es-projector消费事件 → 写 ES 题库索引)
└── content-cache-projector消费事件 → 失效 Redis 缓存)
```
**改造点**
- content 服务只写 MySQL不再直接写 Neo4j/ES
- 新增 `neo4j-projector` / `es-projector` 作为 content 服务内 worker
- content 服务的 GraphQL 子图读 MySQL强一致+ 读 Neo4j/ES最终一致
**GraphQL 子图设计**
```graphql
type Query {
textbook(textbookId: ID!): Textbook # 读 MySQL
textbooks: [Textbook!]! # 读 MySQL
searchQuestions(keyword: String!): [Question!]! # 读 ES
knowledgeGraph(subjectId: ID!): KnowledgeGraph # 读 Neo4j
}
```
**好处**
- 写路径简单(单库事务)
- 读路径可独立扩展ES/Neo4j 可单独扩容)
- 故障隔离ES 挂了不影响写入)
- 数据一致性通过 Outbox + 投影器保证
### 4.3 ai 服务无状态化
**问题**ai 当前有 WorkflowStateStorelesson_plan_workflow是有状态的。
**v2 定位**
- ai 是无状态 LLM 推理引擎
- 不存业务数据,不存会话历史
- 会话历史存 Redis短期或 data-ana长期分析
- 工作流状态存 Rediskey: `ai:workflow:{workflowId}`TTL 1 小时)
**改造点**
- 移除 ai 内部 WorkflowStateStore 的持久化(改为 Redis
- lesson_plan_workflow 状态存 Redis
- ai 用量事件通过 Outbox 发布到 Kafka`edu.ai.usage.recorded` topic
- data-ana 消费 ai 用量事件做统计
### 4.4 data-ana 与 ai 边界
| 维度 | data-ana | ai |
| ---- | --------------------------------- | --------------------------------------- |
| 定位 | 有状态分析引擎 | 无状态推理引擎 |
| 存储 | ClickHouse宽表+ Redis缓存 | 无业务存储(仅 Redis 存 workflow 状态) |
| 输入 | 业务事件Outbox 消费) | 用户请求 + data-ana 数据 |
| 输出 | 统计结果 + 预警 + 掌握度 | LLM 生成内容(聊天/题目/报告) |
| 交互 | ai 调 data-ana 获取分析数据 | data-ana 不调 ai |
**调用关系**
- ai → data-anagRPC获取学生掌握度、班级表现等数据作为 LLM prompt 上下文)
- data-ana → aidata-ana 不依赖 ai
- ai 用量事件 → Kafka → data-ana 消费(统计 token 使用量)
---
## 5. 数据层 + DataScope
### 5.1 存储矩阵v2
| 存储 | 版本 | 用途 | 使用服务 | 变更 |
| ------------- | ---- | ----------------------------------------------- | ----------------------------------------------- | -------------------------- |
| MySQL | 8.0 | 写模型主库(每服务独占 schema | iam / config-service / core-edu / content / msg | 新增 config-service schema |
| Redis | 7 | 缓存 / 会话 / 限流 / Pub/Sub / ai workflow 状态 | 全部服务 | 新增 ai workflow key |
| ClickHouse | 24.3 | 读模型宽表 / 分析聚合 | data-ana | 不变 |
| Neo4j | 5.20 | 知识图谱content 读模型投影) | content | 改为投影器写入 |
| Elasticsearch | 8.13 | 题库检索 / 消息检索(读模型投影) | content / msg | content 改为投影器写入 |
| 配置中心 | — | 动态配置(插件/布局/特性开关) | config-service | 新增 |
### 5.2 DataScope @requires 实现
#### K12 场景规模分析
| 角色 | 可见范围 ID 量级 | 性能评估 |
| -------- | ---------------- | ------------------------- |
| 学生 | 1 个班 | 无压力 |
| 教师 | 5-10 个班 | IN 查询可扛 |
| 教研组长 | 20-50 个班 | IN 查询可扛 |
| 管理员 | 100-500 个班 | 需索引优化 |
| 超管 | 全校 | 走 DataScope=ALL不传 ID |
**结论**K12 场景 ID 列表通常 < 500@requires 运行时解析完全可行。
#### DataScope 6 级(保留 v1
| Level | 含义 | 实现 |
| --------- | ------ | --------------------------------------- |
| L1 SELF | 仅自己 | `WHERE user_id = ?` |
| L2 CLASS | 本班 | `WHERE class_id IN (visibleClassIds)` |
| L3 GRADE | 本年级 | `WHERE grade_id IN (visibleGradeIds)` |
| L4 SCHOOL | 本校 | `WHERE school_id IN (visibleSchoolIds)` |
| L5 REGION | 本区域 | `WHERE region_id IN (visibleRegionIds)` |
| L6 ALL | 全部 | 无 WHERE |
#### @requires 查询计划示例
**场景**:教研组长查"本组所有班级的考试成绩"
**客户端 Query**
```graphql
query GetGradesForMyClasses($termId: ID!) {
gradesForCurrentUser(termId: $termId) {
examId
title
classId
avgScore
}
}
```
**Apollo Router 查询计划**(自动生成):
```
步骤 1: 调 iam 子图
visibleClassIds(userId=x-user-id)
→ iam 查 Redis 缓存key: iam:datascope:class:{userId}TTL 5min
→ 未命中则查 user_school_role 表
→ 返回 ["class-001", ..., "class-050"]
步骤 2: 调 core-edu 子图
gradesForCurrentUser(
visibleClassIds: ["class-001", ..., "class-050"],
termId: "2024-spring"
)
@requires(fields: "visibleClassIds")
→ core-edu 查 MySQL: WHERE class_id IN (...) AND term_id = ?
→ 返回 [Grade, Grade, ...]
步骤 3: 聚合返回客户端
```
#### iam 子图 DataScope Resolver
```typescript
@Resolver(() => User)
export class DataScopeResolver {
constructor(
private iamService: IamService,
@Inject("REDIS") private redis: Redis,
) {}
@ResolveField(() => [String])
async visibleClassIds(
@Parent() user: User,
@Context() ctx: { userId: string; dataScope: string },
): Promise<string[]> {
if (ctx.dataScope === "ALL") return [];
const cacheKey = `iam:datascope:class:${ctx.userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const classIds = await this.iamService.getVisibleClassIds(ctx.userId);
await this.redis.setex(cacheKey, 300, JSON.stringify(classIds));
return classIds;
}
}
```
#### core-edu 子图 @requires Resolver
```typescript
@Resolver(() => Grade)
export class GradeResolver {
constructor(private gradeService: GradeService) {}
@Query(() => [Grade])
@RequirePermission("GRADES_READ")
async gradesForCurrentUser(
@Context()
ctx: {
userId: string;
dataScope: string;
visibleClassIds?: string[];
},
@Args("termId") termId: string,
): Promise<Grade[]> {
if (ctx.dataScope === "ALL") {
return this.gradeService.findByTerm(termId);
}
return this.gradeService.findByClassIds(ctx.visibleClassIds ?? [], termId);
}
}
```
### 5.3 配置中心config-service + Redis
**不引入 etcd/Consul**K12 场景过重),用 config-service + Redis 实现轻量配置中心。
```
Admin 改配置
config-service 写 MySQLplugin_registry 等)
config-service 发 Kafka 事件edu.config.entry.changed
各服务消费事件,失效本地 Redis 缓存
下次查询从 config-service 拉新值,回填 Redis
```
**缓存策略**
- Redis key: `config:{type}:{key}`(如 `config:plugin:grades-widget`
- TTL: 5 分钟(兜底失效)
- 事件驱动失效Kafka `edu.config.entry.changed`
**配置类型**
- 插件配置plugin_registry / role_plugin_mapping
- 布局配置layout_templates / role_layout_default / user_layout_override
- 特性开关feature_flags`enable_ai_tutor`
- 系统配置system_config`max_exam_duration`
### 5.4 Outbox 投影器设计content CQRS
**投影器架构**(默认走 Outbox 事件CDC/Debezium 仅作备选,当 Outbox 事件粒度不够时启用):
```
content 服务写 MySQL
↓ Outbox 事件
Kafkaedu.content.* topic
├── neo4j-projector消费 → 写 Neo4j 知识图谱)
│ · 监听 KnowledgePointCreated / KnowledgePointLinked
│ · 写 Neo4j 节点 + 关系
├── es-projector消费 → 写 ES 题库索引)
│ · 监听 QuestionCreated / QuestionUpdated / QuestionDeleted
│ · 写 ES 索引(题干、选项、知识点标签)
└── content-cache-projector消费 → 失效 Redis 缓存)
· 监听所有 content 事件
· 失效 content 查询缓存
```
**投影器实现位置**:作为 content 服务内的独立 worker`content/src/workers/`
```
content/src/
├── textbooks/
├── questions/
├── knowledge-points/
├── graphql/ # GraphQL 子图(读 MySQL + Neo4j + ES
└── workers/ # 投影器(消费 Kafka → 写读模型)
├── neo4j-projector.worker.ts
├── es-projector.worker.ts
└── cache-projector.worker.ts
```
---
## 6. 事件驱动架构
### 6.1 Outbox vs CDC 职责分工
```
写侧Outbox 模式(领域事件发布)
业务服务iam/core-edu/content/msg/ai
① 业务事务内写业务表 + outbox 表(原子)
② OutboxPublisher 轮询 outbox 表 → 投递 Kafka
③ 保证 at-least-once 语义
用途发布业务语义事件ExamCreated / HomeworkSubmitted
读侧投影器CQRS 读模型 / 跨服务物化视图)
方式 ADebezium → Kafka用于跨服务物化视图同步备选
方式 BOutbox 事件 → 投影器(用于读模型投影,推荐)
用途CQRS 读模型投影,非业务事件发布
```
**关键原则**
- 领域事件必须走 Outbox业务语义由业务代码显式发布
- 数据投影走 CDC 或 Outbox 消费(技术同步,无需业务语义)
- CDC 不替代 OutboxCDC 是 binlog 级Outbox 是业务语义级)
- v2 简化:优先用 Outbox 事件做投影器,减少 Debezium 依赖
### 6.2 Kafka Topic 命名规范
**格式**`edu.<domain>.<aggregate>.<action>`
| 域 | Topic | 生产者 | 消费者 | 用途 |
| -------- | ------------------------------------ | -------------- | ------------------------------ | -------------------- |
| identity | `edu.identity.user.created` | iam | msg / core-edu / content | 用户创建 |
| identity | `edu.identity.user.updated` | iam | msg / core-edu / content | 用户更新 |
| identity | `edu.identity.user.role_changed` | iam | msg / data-ana | 角色变更 |
| identity | `edu.identity.role.created` | iam | msg | 角色创建 |
| teaching | `edu.teaching.exam.published` | core-edu | msg / data-ana | 考试发布 |
| teaching | `edu.teaching.exam.extended` | core-edu | msg | 考试延时 |
| teaching | `edu.teaching.homework.assigned` | core-edu | msg / data-ana | 作业布置 |
| teaching | `edu.teaching.assignment.submitted` | core-edu | msg / data-ana | 作业提交 |
| teaching | `edu.teaching.assignment.graded` | core-edu | msg / data-ana | 作业批改 |
| teaching | `edu.teaching.grade.recorded` | core-edu | msg / data-ana | 成绩录入 |
| teaching | `edu.teaching.attendance.recorded` | core-edu | msg / data-ana | 考勤记录 |
| content | `edu.content.question.created` | content | es-projector / neo4j-projector | 题目创建 |
| content | `edu.content.question.updated` | content | es-projector / neo4j-projector | 题目更新 |
| content | `edu.content.knowledge_point.linked` | content | neo4j-projector | 知识点关联 |
| notify | `edu.notify.notification.sent` | msg | realtime-gateway | 通知发送 |
| notify | `edu.notify.notification.read` | msg | — | 通知已读 |
| notify | `edu.notify.notification.recalled` | msg | realtime-gateway | 通知撤回 |
| insight | `edu.insight.mastery.updated` | data-ana | msg | 掌握度更新 |
| ai | `edu.ai.usage.recorded` | ai | data-ana | AI 用量记录 |
| config | `edu.config.entry.changed` | config-service | 全部服务 | 配置变更(失效缓存) |
**新增 topic**
- `edu.ai.usage.recorded`ai 用量事件(回流 data-ana 统计)
- `edu.config.entry.changed`:配置变更通知(失效各服务本地缓存)
### 6.3 事件版本化
- Schema Registry用 Kafka 的 schema registry或简化为 proto 版本号)
- 版本后缀:`v1` / `v2`(如 `edu.teaching.exam.published.v2`
- 向后兼容:新增字段用 optional删除字段用 reserved
- 破坏性变更:升版本号,新旧 topic 并行,消费者逐步迁移
### 6.4 幂等性
| 组件 | 幂等机制 |
| ---------------- | ------------------------------------------------- |
| Kafka Producer | `idempotent=true` + `transactionalId` |
| Outbox Publisher | 基于 `event_id` 去重Redis SETNX |
| Consumer | 基于 `event_id` 去重DB 唯一索引或 Redis SETNX |
| 投影器 | 基于 `event_id` + `aggregate_id` 去重 |
---
## 7. 认证与权限
### 7.1 JWT 流程v2
```
用户登录
api-gateway → iam /auth/login
iam 校验密码 → 签发 JWT RS256含 userId / role / dataScope
api-gateway 设置 httpOnly CookieJWT
portal-shell 后续请求携带 Cookie
api-gateway 校验 JWT → 注入 x-user-id / x-user-role / x-dataScope / x-request-id
apollo-router 透传 x-user-* 头到子图
子图 Resolver 从 context 读取 userId / dataScope
iam 子图 DataScope Resolver 查 visibleClassIdsRedis 缓存)
core-edu 子图 @requires 接收 visibleClassIds注入 WHERE
```
### 7.2 权限校验分层
| 层级 | 职责 | 实现 |
| -------------- | ------------------------------ | ---------------------------- |
| api-gateway | JWT 校验 + 限流 | Go 中间件 |
| apollo-router | 透传 x-user-* 头 | 配置 headers.propagate |
| 子图 Resolver | @RequirePermission 装饰器 | NestJS Guard |
| 子图 DataScope | @requires 注入 visibleClassIds | Apollo Federation 指令 |
| Repository | WHERE 注入 visibleClassIds | DataScopeInjector保留 v1 |
### 7.3 DEV_MODE
- `DEV_MODE=true` 跳过 JWT 校验,接受 `dev-token`
- 保留现有 dev-token 预定义角色机制
---
## 8. 可观测性 + 工作流 + 推送
### 8.1 可观测性
| 支柱 | 组件 | v2 变更 |
| -------- | -------------------------------------------- | -------------------------- |
| 日志 | pino / zap / structlog | 不变 |
| 指标 | prom-client / prometheus / prometheus-client | 新增 apollo-router 指标 |
| 链路 | OpenTelemetry + Jaeger 1.57 | 不变 |
| 健康检查 | /healthz + /readyz | 新增 apollo-router /health |
**apollo-router 可观测性**
- 内置 Prometheus 指标(请求量/延迟/错误率/子图延迟)
- 内置 OpenTelemetry trace
- 内置持续查询Persisted Queries统计
### 8.2 工作流边界Temporal 约束)
**v2 决策**:不引入 Temporal保留 ai 服务的 WorkflowStateStore 作为轻量替代。
**架构约束**
| 工作流类型 | 时长 | 推荐方案 | 示例 |
| ---------- | ------- | -------------------------- | -------------------- |
| 长工作流 | 分钟~天 | Redis 状态机 + Outbox 事件 | 备课工作流、报告生成 |
| 短事务 | 毫秒~秒 | 同步调用 + 分布式锁 | 作业提交、成绩录入 |
| 批处理 | 小时 | Cron + Batch Job | 成绩统计、掌握度计算 |
**禁止**
- 短事务用工作流引擎(入库开销拖垮吞吐)
- 纯读操作走工作流(直接走缓存)
**允许**
- 跨天长流程用状态机 + 事件驱动
- ai 备课/报告生成用 Redis 状态机WorkflowStateStore
### 8.3 推送架构SSE 优先)
```
msg 服务发布通知
↓ Outbox → Kafka
realtime-gateway 消费 Kafkaedu.notify.notification.*
├── SSE 推送(默认,单向)
│ · 客户端 GET /sse?token=JWT保持长连接
│ · 服务端 push 事件流
│ · 适合 99% 场景(考试发布/成绩推送/通知)
└── WebSocket 推送(仅监考场景)
· 客户端 WS /ws双向通信
· 用于在线监考(心跳/防作弊/实时指令)
· MVP 可不实现,二期按需
```
**realtime-gateway 端点**
- `GET /sse` — SSE 推送(默认)
- `GET /ws` — WebSocket 升级(监考专用)
- `POST /internal/push` — msg 服务 HTTP 调用推送
- `GET /online/:userId` — 查询在线状态
- `GET /healthz` / `GET /readyz` / `GET /metrics`
**性能对比**
- SSE单连接内存 ~10KB可扛 10 万连接/节点
- WS单连接内存 ~100KB可扛 1 万连接/节点
---
## 9. 架构约束v2 新增)
| 约束 | 说明 |
| ----------------------- | --------------------------------------------------------------------------------------- |
| **BFF 联邦化** | 禁止手写 BFF 聚合层,所有 GraphQL 查询走 Apollo Router |
| **子图自主** | 每个业务服务必须暴露 GraphQL 子图proto → GraphQL 自动生成 |
| **DataScope @requires** | 禁止跨库 JOINDataScope 通过 @requires 指令运行时解析 |
| **Outbox 领域事件** | 领域事件必须走 Outbox禁止直接调 Kafka producer |
| **CDC 仅备选投影** | CDC/Debezium 默认不启用,仅当 Outbox 事件粒度不够时作为投影器备选;禁止用于领域事件发布 |
| **SSE 优先** | 推送默认走 SSEWS 仅限强双向场景(监考) |
| **Temporal 禁用** | 不引入 Temporal长工作流用 Redis 状态机 + Outbox |
| **iam 职责限定** | iam 仅负责认证/RBAC/JWKS/审计/DataScope禁止承载配置 |
| **config 统一** | 动态配置必须走 config-service禁止散落 env/code |
| **content CQRS** | content 仅写 MySQLNeo4j/ES 通过投影器同步 |
| **ai 无状态** | ai 不存业务数据,会话/工作流状态存 Redis |
| **单 portal-shell** | 前端单容器部署,禁止新增独立 portal |
**保留约束v1**
- 四层分层Gateway → Router → Services → Data
- 依赖方向单向,禁止反向依赖
- 服务间通信GraphQL@requires+ gRPC服务间+ Kafka事件
- 每服务独占 DB schema
- JWT RS256 + JWKS
- Cookie: httpOnly + Secure + SameSite=Strict
- buf v2 + FILE 级 breaking 检查
---
## 10. 迁移路径
### 10.1 阶段划分(从 v1 到 v2
| 阶段 | 内容 | 验收标准 | 依赖 |
| ---- | ----------------------------------------- | ---------------------------------- | ----- |
| M0 | proto → GraphQL 代码生成工具链 | `buf generate` 输出 GraphQL schema | 无 |
| M1 | 各服务暴露 GraphQL 子图 | `/graphql` 端点可查询 | M0 |
| M2 | apollo-router 部署 + Supergraph 组装 | Router 可聚合查询 | M1 |
| M3 | iam 拆分 config-service | config-service 独立运行 | M1 |
| M4 | DataScope @requires 实现 | 教师查询可见班级成绩正确 | M2 |
| M5 | content CQRS 改造(投影器) | Neo4j/ES 通过投影器同步 | M1 |
| M6 | ai 无状态化 | ai 不存业务数据,状态在 Redis | M1 |
| M7 | realtime-gateway SSE 优先 | SSE 推送可用 | 无 |
| M8 | portal-shell 接入 apollo-router | portal-shell 查询走 Router | M2/M4 |
| M9 | 旧 BFF 下线teacher/student/parent-bff | 流量为 0 | M8 |
| M10 | 旧 portal 下线 | 流量为 0 | M8 |
| M11 | arch.db + 004 文档同步 | arch:scan 通过 | 全部 |
### 10.2 并行运行策略
- v1 的 3 个 BFF + 4 个 portal 保留并行运行
- v2 的 apollo-router + portal-shell 新建
- 用户通过路由前缀切换(`/shell/*` 走新,其他走旧)
- 逐步迁移用户,流量切完后下线旧服务
### 10.3 回滚策略
- 任意阶段失败,回滚到上一阶段
- v1 服务始终可用v2 失败不影响现有用户
- iam 拆分 config-service 时config 表与现有表无外键依赖,可独立回滚
---
## 11. ADR 记录
| ADR | 决策 | 理由 |
| ------- | ---------------------------------------- | -------------------------------- |
| ADR-023 | 采用 Apollo Federation 替代 3 个手写 BFF | 工业标准,消灭样板代码 |
| ADR-024 | DataScope 通过 @requires 运行时解析 | 解决跨库 JOIN 悖论 |
| ADR-025 | proto → GraphQL 自动生成 | 单一数据源,字段变更只改 proto |
| ADR-026 | iam 拆分 config-service | 避免上帝服务 |
| ADR-027 | content CQRS 改造 | 写读分离,故障隔离 |
| ADR-028 | ai 无状态化 | 明确边界,可扩展 |
| ADR-029 | SSE 优先 + WS 仅监考 | K12 场景单向为主 |
| ADR-030 | 不引入 Temporal | 短事务禁用,长工作流用轻量状态机 |
| ADR-031 | config-service + Redis 轻量配置中心 | K12 场景,不引入 etcd |
| ADR-032 | CDC 默认禁用,投影器首选 Outbox 事件 | 与 Outbox 职责分工,简化依赖 |
| ADR-033 | portal-shell 单容器替代 4 portal | Modular Monolith |
| ADR-034 | realtime-gateway 改名 | SSE 优先定位 |
---
## 12. 对接清单
### 12.1 新增服务
| 路径 | 职责 | 动作 |
| -------------------------- | -------------------------------------------------- | ---- |
| `services/apollo-router/` | Apollo Router 部署配置router.yaml + Dockerfile | 新建 |
| `services/config-service/` | 配置服务NestJS从 iam 拆出) | 新建 |
| `apps/portal-shell/` | Portal Shell 前端(详见 portal-shell v2.1 设计稿) | 新建 |
### 12.2 改造服务
| 路径 | 变更 | 动作 |
| ------------------------ | ------------------------------------------------------------ | ---- |
| `services/iam/` | 移除插件配置相关表和逻辑到 config-service新增 GraphQL 子图 | 改造 |
| `services/core-edu/` | 新增 GraphQL 子图;实现 @requires DataScope | 改造 |
| `services/content/` | CQRS 改造:移除直接写 Neo4j/ES新增投影器 worker | 改造 |
| `services/msg/` | 新增 GraphQL 子图 | 改造 |
| `services/data-ana/` | 新增 GraphQL 子图 | 改造 |
| `services/ai/` | 无状态化WorkflowStateStore 改为 Redis新增 GraphQL 子图 | 改造 |
| `services/push-gateway/` | 改名 realtime-gatewaySSE 优先 | 改造 |
| `services/api-gateway/` | 移除 BFF 代理路由;新增 apollo-router 路由 | 改造 |
### 12.3 下线服务
| 路径 | 处理 | 时机 |
| ----------------------- | ----------------------------- | ---- |
| `services/teacher-bff/` | 流量切到 apollo-router 后下线 | M9 |
| `services/student-bff/` | 流量切到 apollo-router 后下线 | M9 |
| `services/parent-bff/` | 流量切到 apollo-router 后下线 | M9 |
| `apps/teacher-portal/` | 流量切到 portal-shell 后下线 | M10 |
| `apps/student-portal/` | 流量切到 portal-shell 后下线 | M10 |
| `apps/parent-portal/` | 流量切到 portal-shell 后下线 | M10 |
| `apps/admin-portal/` | 流量切到 portal-shell 后下线 | M10 |
### 12.4 新增工具链
| 路径 | 职责 | 动作 |
| -------------------------------------- | ----------------------------- | ---- |
| `packages/shared-proto/gen-graphql.ts` | proto → GraphQL schema 生成器 | 新建 |
| `packages/shared-proto/buf.gen.yaml` | 新增 GraphQL 输出配置 | 修改 |
### 12.5 基础设施
| 路径 | 变更 | 动作 |
| --------------------------------- | ------------------------------------------------------------------------- | ---- |
| `infra/docker-compose.deploy.yml` | 新增 apollo-router / config-service / portal-shell移除 3 BFF + 4 portal | 修改 |
| `infra/port-allocation.md` | 新增 3011 / 4000 / 4010 端口 | 修改 |
| `infra/init-sql/` | 新增 config-service schema DDL | 新增 |
---
## 13. 验收标准
### 13.1 功能验收
- [ ] Apollo Router 可聚合 7 个子图查询
- [ ] DataScope @requires 正确传递 visibleClassIds
- [ ] iam 拆分后 config-service 独立运行
- [ ] content CQRS 投影器正确同步 Neo4j/ES
- [ ] ai 无状态化,工作流状态在 Redis
- [ ] realtime-gateway SSE 推送可用
- [ ] portal-shell 接入 apollo-router 查询正常
- [ ] 配置变更通过 Kafka 事件失效各服务缓存
### 13.2 非功能验收
- [ ] `pnpm run lint` + `pnpm run typecheck` 零错误
- [ ] Apollo Router 查询延迟 P99 < 100ms不含子图
- [ ] DataScope @requires 查询延迟 P99 < 500ms含子图
- [ ] SSE 单节点可扛 10 万连接
- [ ] arch.db 更新004 文档同步
- [ ] 所有 ADR 记录完整
---
## 14. 参考资料
- [Apollo Federation 官方文档](https://www.apollographql.com/docs/federation/)
- [Apollo Router 配置](https://www.apollographql.com/docs/router/configuration)
- [GraphQL @requires 指令](https://www.apollographql.com/docs/federation/federated-types/federated-directives/#requires)
- [CQRS 模式](https://martinfowler.com/bliki/CQRS.html)
- [Outbox 模式](https://microservices.io/patterns/data/transactional-outbox.html)
- [SSE vs WebSocket](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [Portal Shell v2.1 设计稿](./2026-07-14-portal-shell-widget-dashboard-design.md)
- [004 架构影响地图](../../architecture/004_architecture_impact_map.md)
- [项目规则](../../.trae/rules/project_rules.md)
---
## 15. 版本演进对比
| 维度 | v1当前 | v2本设计 |
| -------------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| 前端 | 4 portal + MF | 单 portal-shell |
| BFF | 3 个手写 BFF | Apollo Federation |
| DataScope | 网关透传 + 服务注入 | @requires 运行时解析 |
| Push | WS + SSE | SSE 优先 + WS 仅监考 |
| Temporal | 规划引入 | 不引入 |
| iam | 认证+RBAC+插件配置 | 拆分 iam + config-service |
| content | MySQL+Neo4j+ES 混写 | CQRSMySQL 写 + 投影器 |
| ai | 有状态工作流 | 无状态推理 |
| 配置 | env + DB 散落 | config-service 统一 |
| 服务数(业务+网关+前端) | 15 个1 gw + 1 push + 3 BFF + 6 services + 4 portal | 11 个1 gw + 1 realtime + 1 router + 7 services + 1 shell替换 3 BFF + 4 portal 为 1 Router + 1 Shell + 1 config |
| 容器数(含数据层 6 个MySQL/Redis/Kafka/ClickHouse/Neo4j/ES | 21 | 17 |