模块架构设计文档 — content
AI 标识:ai05
负责模块:content(P4)
阶段:架构设计外包 · 阶段 2(模块架构设计)
日期:2026-07-09
版本:v1.0
关联文档:01-understanding.md、ai-allocation.md、004 架构影响地图、pending-features.md、known-issues.md、project_rules.md
0. 设计哲学与文档定位
0.1 设计哲学
content 服务承载内容资源中台职责,设计哲学遵循四条原则:
- DDD 限界上下文:Textbook / Chapter / KnowledgePoint / Question 四个聚合根,明确边界,禁止跨聚合直接持有引用
- Polyglot Persistence 多存储分工:MySQL 存权威写模型,Neo4j 存知识图谱关系,ES 存检索读模型,三者职责不重叠
- 事件驱动同步:跨存储同步通过 Outbox + Kafka 异步事件,禁止业务事务内同步双写(违反 004 §12.2 强制条款)
- CQRS 读模型分离:写模型走 Drizzle ORM 直写 MySQL;读模型(ES 全文检索、知识图谱查询)独立查询路径,未来可演化为独立读服务
0.2 长远演进目标
content 不止服务 P4 CRUD 阶段,需为以下未来场景预留架构弹性:
| 时间线 |
演进方向 |
当前架构预留点 |
| P4 完成 |
CRUD + 知识图谱 + Outbox + 基础事件发布 |
Outbox 表 + Kafka producer + Neo4j 异步同步 consumer |
| P5 |
ES 全文检索 + gRPC 完整契约(含 CreateQuestions 供 AI 调用) |
ES mapping 设计 + gRPC controller 预留 + proto message 完整定义 |
| P6+ |
AI 辅助出题 deep integration(知识点推荐 → AI 生成题目 → 入库审核) |
QuestionService.CreateQuestions gRPC + 审核工作流预留字段 |
| 未来 |
多模态内容(视频/音频/AR)、跨租户内容共享、个性化学习路径推荐 |
metadata jsonb 字段预留 + tenant_id 字段预留 + LearningPath RPC |
| 未来 |
知识图谱可视化、图神经网络分析、教材版本管理 |
Neo4j 节点属性可扩展 + version 字段 + 图算法 Cypher 预留 |
| 未来 |
内容合规审核工作流(人工/AI 审核) |
status 字段含 draft/pending_review/published/archived 状态机 |
0.3 文档结构说明
本设计文档遵循 ai-allocation.md §7 模板的 8 节结构,并补充第 9 节"演进路线"与第 10 节"风险与假设"。所有跨模块契约点均回标到 004 架构影响地图对应章节。
1. 模块内部分层图
1.1 整体分层架构
1.2 请求处理链路
| 阶段 |
组件 |
职责 |
| 入口 |
NestJS ExpressAdapter / gRPC server |
HTTP 3005 / gRPC 50054 |
| 鉴权 |
AuthMiddleware |
信任 Gateway 注入的 x-user-id / x-user-roles 头(004 §4.1 Gateway 职责) |
| 授权 |
PermissionGuard (APP_GUARD) |
校验 16 个 CONTENT_* 权限点 |
| 校验 |
Zod Schema Parse |
Controller 层解析 body/query/param,失败抛 ZodError |
| 业务 |
ApplicationService |
编排 Repository + Outbox + 跨聚合调用 |
| 持久化 |
Repository (Drizzle ORM) |
单一数据访问入口,事务边界 |
| 事件 |
Outbox Publisher |
独立 worker 轮询 outbox_events 表投递 Kafka |
| 异步 |
Kafka Consumer |
消费 core-edu 教学内容变更事件 |
| 同步 |
Neo4j / ES Sync Worker |
消费 content 自身事件同步到 Neo4j / ES |
| 错误 |
GlobalErrorFilter |
捕获 ApplicationError + ZodError,结构化响应 |
| 观测 |
Logger / Metrics / Tracer |
全链路 traceparent 传递 |
1.3 异步链路与同步链路分离
同步链路(用户请求响应路径):
- 仅写 MySQL 主库 + outbox_events 表(同一事务)
- 不直接写 Neo4j / ES(违反事件驱动原则)
异步链路(事件驱动路径):
- Outbox Publisher → Kafka → Neo4j Sync Worker(同步知识点节点)
- Outbox Publisher → Kafka → ES Sync Worker(同步题目索引)
- Kafka Consumer ← core-edu 事件 → 触发内容失效/更新
2. 领域模型
2.1 聚合根与实体
2.2 值对象
| 值对象 |
字段 |
用途 |
TextbookStatus |
draft / pending_review / published / archived |
教材状态机 |
ChapterStatus |
draft / published / archived |
章节状态机 |
QuestionStatus |
draft / pending_review / published / rejected / archived |
题目状态机(含审核流程) |
QuestionType |
single_choice / multiple_choice / short_answer / essay |
题型枚举 |
QuestionSource |
manual / ai_generated / imported |
题目来源(为 AI 出题预留) |
Difficulty |
1 / 2 / 3 / 4 / 5 |
难度等级(5 级) |
2.3 聚合间通信规则
| 场景 |
通信方式 |
理由 |
| Textbook 聚合查 Chapter 列表 |
同服务内 Repository 直接查询 |
同一限界上下文内,强一致 |
| Chapter 删除时联动 KnowledgePoint |
同服务内 Service 编排,事务内级联 |
强一致 |
| KnowledgePoint 增删 → 更新 Neo4j 图 |
Outbox 事件 → Kafka → Neo4j Sync Worker |
跨存储最终一致 |
| Question 发布 → ES 索引 |
Outbox 事件 → Kafka → ES Sync Worker |
跨存储最终一致 |
| core-edu 教学内容变更 → content 失效 |
Kafka 事件消费 → 标记 status=archived |
跨服务最终一致 |
2.4 状态机设计
Textbook 状态机
Question 状态机(含 AI 出题审核流程预留)
设计预留:source 字段区分 manual / ai_generated,为 P5+ AI 辅助出题流程预留溯源能力。
3. 数据模型
3.1 MySQL Schema(写模型主库)
3.1.1 textbooks 表
| 字段 |
类型 |
约束 |
说明 |
| id |
varchar(32) |
PK |
cuid2 |
| title |
varchar(255) |
NOT NULL |
教材标题 |
| subject_id |
varchar(32) |
NOT NULL, INDEX |
学科 ID(关联 iam/学科字典) |
| grade_id |
varchar(32) |
NOT NULL, INDEX |
年级 ID |
| version |
varchar(32) |
NOT NULL DEFAULT '1.0' |
教材版本(未来支持多版本) |
| status |
varchar(32) |
NOT NULL DEFAULT 'draft' |
状态机字段 |
| tenant_id |
varchar(32) |
NULL, INDEX |
租户 ID(多租户预留) |
| metadata |
json |
NULL |
扩展字段(出版社/作者/ISBN 等) |
| created_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP |
|
| updated_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE |
|
索引:
- PRIMARY KEY (
id)
- INDEX
idx_textbooks_subject_grade (subject_id, grade_id)
- INDEX
idx_textbooks_status (status)
- INDEX
idx_textbooks_tenant (tenant_id)
3.1.2 chapters 表(补齐时间戳)
| 字段 |
类型 |
约束 |
说明 |
| id |
varchar(32) |
PK |
cuid2 |
| textbook_id |
varchar(32) |
NOT NULL, INDEX |
外键(应用层校验) |
| title |
varchar(255) |
NOT NULL |
|
| order_num |
int |
NOT NULL DEFAULT 0 |
排序字段(注意:DB 列名 order_num,TS schema 字段名 order) |
| parent_id |
varchar(32) |
NULL, INDEX |
父章节 ID(树形结构) |
| status |
varchar(32) |
NOT NULL DEFAULT 'draft' |
|
| created_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP |
补齐 |
| updated_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE |
补齐 |
索引:
- PRIMARY KEY (
id)
- INDEX
idx_chapters_textbook_order (textbook_id, order_num)
- INDEX
idx_chapters_parent (parent_id)
3.1.3 knowledge_points 表(补齐时间戳)
| 字段 |
类型 |
约束 |
说明 |
| id |
varchar(32) |
PK |
cuid2 |
| chapter_id |
varchar(32) |
NOT NULL, INDEX |
外键 |
| title |
varchar(255) |
NOT NULL |
|
| description |
text |
NULL |
|
| difficulty |
tinyint |
NOT NULL DEFAULT 3 |
1-5 难度等级 |
| metadata |
json |
NULL |
扩展字段(关键词/标签/资源链接) |
| created_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP |
补齐 |
| updated_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE |
补齐 |
索引:
- PRIMARY KEY (
id)
- INDEX
idx_kp_chapter (chapter_id)
3.1.4 questions 表(扩展字段)
| 字段 |
类型 |
约束 |
说明 |
| id |
varchar(32) |
PK |
cuid2 |
| knowledge_point_id |
varchar(32) |
NOT NULL, INDEX |
外键 |
| type |
varchar(32) |
NOT NULL |
QuestionType 枚举 |
| content |
text |
NOT NULL |
题干(HTML/markdown) |
| options |
json |
NULL |
选项(选择题) |
| answer |
text |
NOT NULL |
标准答案 |
| explanation |
text |
NULL |
解析 |
| difficulty |
tinyint |
NOT NULL DEFAULT 3 |
1-5 |
| status |
varchar(32) |
NOT NULL DEFAULT 'draft' |
新增 状态机字段 |
| source |
varchar(32) |
NOT NULL DEFAULT 'manual' |
新增 manual/ai_generated/imported |
| created_by |
varchar(32) |
NOT NULL |
新增 创建者 user_id |
| metadata |
json |
NULL |
新增 扩展(标签/年份/来源) |
| created_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP |
|
| updated_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE |
|
索引:
- PRIMARY KEY (
id)
- INDEX
idx_questions_kp (knowledge_point_id)
- INDEX
idx_questions_type_difficulty (type, difficulty)
- INDEX
idx_questions_status (status)
- INDEX
idx_questions_source (source)
3.1.5 content_outbox_events 表(Outbox 模式 · 新增)
| 字段 |
类型 |
约束 |
说明 |
| event_id |
varchar(64) |
PK |
UUID v4(producer idempotency key) |
| aggregate_type |
varchar(64) |
NOT NULL |
'Textbook' / 'Chapter' / 'KnowledgePoint' / 'Question' |
| aggregate_id |
varchar(32) |
NOT NULL |
聚合根 ID |
| event_type |
varchar(64) |
NOT NULL |
'edu.content.question.published' 等 |
| topic |
varchar(128) |
NOT NULL |
Kafka topic 名 |
| payload |
json |
NOT NULL |
事件 JSON |
| status |
varchar(16) |
NOT NULL DEFAULT 'PENDING' |
PENDING/PROCESSING/SENT/FAILED |
| retry_count |
int |
NOT NULL DEFAULT 0 |
重试次数 |
| created_at |
timestamp |
NOT NULL DEFAULT CURRENT_TIMESTAMP |
|
| published_at |
timestamp |
NULL |
投递成功时间 |
| next_retry_at |
timestamp |
NULL |
下次重试时间(指数退避) |
索引:
- PRIMARY KEY (
event_id)
- INDEX
idx_outbox_status_retry (status, next_retry_at)
- INDEX
idx_outbox_aggregate (aggregate_type, aggregate_id)
3.2 Neo4j 图模型
3.2.1 节点定义
3.2.2 关系定义
| 关系类型 |
方向 |
属性 |
用途 |
PREREQUISITE_OF |
(kp1)-[:PREREQUISITE_OF]->(kp2) |
createdAt |
kp1 是 kp2 的前置知识点 |
BELONGS_TO_CHAPTER |
(kp)-[:BELONGS_TO_CHAPTER]->(ch) |
— |
知识点归属章节 |
BELONGS_TO_TEXTBOOK |
(ch)-[:BELONGS_TO_TEXTBOOK]->(tb) |
— |
章节归属教材 |
3.2.3 典型查询
3.3 Elasticsearch 索引设计(P5 引入)
3.3.1 questions 索引 mapping
3.3.2 索引管理
- 索引创建:服务启动时
ensureIndex('questions', mapping) 幂等
- 数据同步:消费
edu.content.question.published / edu.content.question.updated / edu.content.question.deleted 事件增量更新
- 重建索引:提供
POST /internal/reindex 端点全量重建(P5+ 预留)
3.4 读写分离策略(CQRS)
| 操作 |
路径 |
说明 |
| 写(创建/更新/删除) |
Service → Repository → MySQL + Outbox |
单一写模型,事务保证 |
| 读 - CRUD 列表 |
Service → Repository → MySQL |
默认读路径 |
| 读 - 全文检索 |
Service → ES Client → ES |
检索专用读模型(P5+) |
| 读 - 知识图谱 |
Service → Neo4j Session → Neo4j |
图谱专用读模型 |
| 读 - 学习路径 |
Service → Neo4j + MySQL JOIN |
混合读路径 |
4. API 设计
4.1 REST API(当前 P4 已实现 + 待补齐)
| Method |
Path |
权限 |
请求体 |
响应 |
说明 |
| POST |
/textbooks |
CONTENT_TEXTBOOK_CREATE |
{title, subjectId, gradeId, version?, metadata?} |
{id, ...} |
创建教材 |
| GET |
/textbooks |
CONTENT_TEXTBOOK_READ |
?subjectId=&gradeId=&page=1&pageSize=20 |
{items[], total, page} |
分页列表(待补分页) |
| GET |
/textbooks/:id |
CONTENT_TEXTBOOK_READ |
— |
{id, ...} |
详情 |
| PUT |
/textbooks/:id |
CONTENT_TEXTBOOK_UPDATE |
{title?, status?, metadata?} |
{id, ...} |
更新 |
| DELETE |
/textbooks/:id |
CONTENT_TEXTBOOK_DELETE |
— |
{success: true} |
删除 |
| POST |
/chapters |
CONTENT_CHAPTER_CREATE |
{textbookId, title, order, parentId?} |
{id, ...} |
创建章节 |
| GET |
/chapters |
CONTENT_CHAPTER_READ |
?textbookId=&page=&pageSize= |
{items[], total} |
列表 |
| GET |
/chapters/:id |
CONTENT_CHAPTER_READ |
— |
{id, ...} |
详情 |
| PUT |
/chapters/:id |
CONTENT_CHAPTER_UPDATE |
{title?, order?, status?} |
{id, ...} |
更新 |
| DELETE |
/chapters/:id |
CONTENT_CHAPTER_DELETE |
— |
{success: true} |
删除 |
| POST |
/knowledge-points |
CONTENT_KNOWLEDGE_POINT_CREATE |
{chapterId, title, description?, difficulty?} |
{id, ...} |
创建知识点 |
| GET |
/knowledge-points |
CONTENT_KNOWLEDGE_POINT_READ |
?chapterId=&page=&pageSize= |
{items[], total} |
列表 |
| GET |
/knowledge-points/:id |
CONTENT_KNOWLEDGE_POINT_READ |
— |
{id, ...} |
详情 |
| PUT |
/knowledge-points/:id |
CONTENT_KNOWLEDGE_POINT_UPDATE |
{title?, description?, difficulty?} |
{id, ...} |
更新 |
| DELETE |
/knowledge-points/:id |
CONTENT_KNOWLEDGE_POINT_DELETE |
— |
{success: true} |
删除 |
| GET |
/knowledge-points/:id/prerequisites |
CONTENT_KNOWLEDGE_POINT_READ |
?depth=5 |
{points[]} |
前置链路 |
| POST |
/knowledge-points/:id/prerequisites |
CONTENT_KNOWLEDGE_POINT_UPDATE |
{prerequisiteId} |
{success: true} |
添加前置 |
| DELETE |
/knowledge-points/:id/prerequisites/:prereqId |
CONTENT_KNOWLEDGE_POINT_UPDATE |
— |
{success: true} |
删除前置 |
| POST |
/questions |
CONTENT_QUESTION_CREATE |
{knowledgePointId, type, content, options?, answer, explanation?, difficulty?, metadata?} |
{id, ...} |
创建题目 |
| GET |
/questions |
CONTENT_QUESTION_READ |
?knowledgePointId=&type=&difficulty=&status=&page=&pageSize= |
{items[], total} |
列表(含筛选) |
| GET |
/questions/:id |
CONTENT_QUESTION_READ |
— |
{id, ...} |
详情 |
| PUT |
/questions/:id |
CONTENT_QUESTION_UPDATE |
{content?, options?, answer?, explanation?, difficulty?, status?} |
{id, ...} |
更新 |
| DELETE |
/questions/:id |
CONTENT_QUESTION_DELETE |
— |
{success: true} |
删除 |
| GET |
/questions/search |
CONTENT_QUESTION_READ |
?q=&type=&difficulty=&knowledgePointId=&page=&pageSize= |
{items[], total} |
ES 全文检索(P5) |
4.2 gRPC API(待实现,对齐 004 §4.2 P4 启用)
4.2.1 TextbookService
| RPC |
请求 |
响应 |
说明 |
| CreateTextbook |
CreateTextbookRequest{title, subject_id, grade_id, version} |
Textbook |
|
| GetTextbook |
GetTextbookRequest{id} |
Textbook |
|
| ListTextbooks |
ListTextbooksRequest{subject_id, grade_id, page_token, page_size} |
ListTextbooksResponse{textbooks[], next_page_token} |
待补分页 |
| UpdateTextbook |
UpdateTextbookRequest{id, title?, status?} |
Textbook |
新增 |
| DeleteTextbook |
DeleteTextbookRequest{id} |
Empty |
新增 |
4.2.2 ChapterService(新增)
| RPC |
请求 |
响应 |
说明 |
| CreateChapter |
CreateChapterRequest{textbook_id, title, order, parent_id?} |
Chapter |
|
| ListChapters |
ListChaptersRequest{textbook_id, parent_id?} |
ListChaptersResponse{chapters[]} |
|
| GetChapter |
GetChapterRequest{id} |
Chapter |
|
4.2.3 KnowledgeGraphService
| RPC |
请求 |
响应 |
说明 |
| GetPrerequisites |
GetPrerequisitesRequest{knowledge_point_id, depth?} |
KnowledgePointsResponse{points[]} |
前置依赖 |
| GetLearningPath |
GetLearningPathRequest{student_id, subject_id} |
LearningPath{points[], recommended_order[]} |
学习路径推荐 |
| AddPrerequisite |
AddPrerequisiteRequest{kp_id, prerequisite_id} |
Empty |
新增 |
| RemovePrerequisite |
RemovePrerequisiteRequest{kp_id, prerequisite_id} |
Empty |
新增 |
4.2.4 QuestionService(新增 · 阻塞 P5 AI 辅助出题)
| RPC |
请求 |
响应 |
说明 |
| CreateQuestion |
CreateQuestionRequest{knowledge_point_id, type, content, options?, answer, explanation?, difficulty?, source?, created_by?} |
Question |
新增:供 AI 服务调用入库 |
| BatchCreateQuestions |
BatchCreateQuestionsRequest{questions[]} |
BatchCreateQuestionsResponse{ids[], failed[]} |
新增:AI 批量出题入库 |
| GetQuestion |
GetQuestionRequest{id} |
Question |
|
| ListQuestions |
ListQuestionsRequest{knowledge_point_id?, type?, difficulty?, status?, page_token, page_size} |
ListQuestionsResponse{questions[], next_page_token} |
|
| UpdateQuestion |
UpdateQuestionRequest{id, content?, answer?, status?} |
Question |
|
| DeleteQuestion |
DeleteQuestionRequest{id} |
Empty |
|
关键阻塞项:004 §9.3 明确"AI → Content gRPC CreateQuestions 入库",但 content.proto 当前缺 QuestionService RPC,是 P5 阻塞性契约缺失,阶段 2 设计必须补齐并向 coord 提请 proto 变更。
4.3 错误响应结构
4.4 分页统一规范
| 字段 |
类型 |
说明 |
page |
number |
页码,从 1 开始 |
pageSize |
number |
每页条数,默认 20,最大 100 |
total |
number |
总数 |
items |
array |
当前页数据 |
gRPC 使用 page_token / next_page_token(cursor 模式),REST 使用 page / pageSize(offset 模式),两者并行存在。
5. 事件设计
5.1 发布事件清单
| Event Type |
Topic |
触发时机 |
Payload |
消费者 |
edu.content.textbook.created |
edu.content.textbook.created |
创建教材后 |
{event_id, aggregate_id, occurred_at, textbook_id, title, subject_id, grade_id, version} |
data-ana(学情分析) |
edu.content.textbook.updated |
edu.content.textbook.updated |
更新教材后 |
{...textbook, fields_changed[]} |
data-ana |
edu.content.textbook.published |
edu.content.textbook.published |
教材发布(状态机变更) |
{textbook_id, published_at} |
data-ana、msg(通知教师) |
edu.content.chapter.created |
edu.content.chapter.created |
创建章节后 |
{chapter_id, textbook_id, title, order} |
data-ana |
edu.content.knowledge_point.created |
edu.content.knowledge_point.created |
创建知识点后 |
{kp_id, chapter_id, title, difficulty} |
data-ana、Neo4j Sync Worker |
edu.content.knowledge_point.updated |
edu.content.knowledge_point.updated |
更新知识点后 |
{kp_id, fields_changed[]} |
data-ana、Neo4j Sync Worker |
edu.content.knowledge_point.prerequisite_added |
edu.content.knowledge_point.prerequisite_added |
添加前置依赖 |
{kp_id, prerequisite_id} |
Neo4j Sync Worker |
edu.content.knowledge_point.prerequisite_removed |
edu.content.knowledge_point.prerequisite_removed |
删除前置依赖 |
{kp_id, prerequisite_id} |
Neo4j Sync Worker |
edu.content.question.created |
edu.content.question.created |
创建题目后 |
{question_id, kp_id, type, difficulty, source} |
data-ana、ES Sync Worker |
edu.content.question.updated |
edu.content.question.updated |
更新题目后 |
{question_id, fields_changed[]} |
ES Sync Worker |
edu.content.question.published |
edu.content.question.published |
题目发布(状态机变更) |
{question_id, kp_id, type, published_at} |
AI(出题参考)、ES Sync Worker、data-ana |
edu.content.question.deleted |
edu.content.question.deleted |
删除题目后 |
{question_id} |
ES Sync Worker(删除索引) |
5.2 消费事件清单
| Topic |
来源服务 |
处理逻辑 |
幂等键 |
edu.teaching.exam.published |
core-edu |
联动相关题目状态变更(可选) |
event_id |
edu.teaching.content.invalidated(待 ai03 确认 topic) |
core-edu |
教学内容失效通知,标记 status=archived |
event_id |
5.3 TOPIC_MAP 路由表(content 内部)
5.4 Outbox Publisher 模式
5.5 幂等去重设计
- Producer 侧:Kafka producer 开启
idempotent=true + transactionalId=content-producer
- Consumer 侧:基于
event_id 字段去重,存储到 Redis SETNX 或独立 processed_events 表
- Outbox 重投递:消费者需容忍重复事件(同一 event_id 多次投递不产生副作用)
6. 横切关注点对齐清单
6.1 权限装饰器清单
| Controller 方法 |
权限点 |
说明 |
| TextbooksController.create |
CONTENT_TEXTBOOK_CREATE |
|
| TextbooksController.findAll |
CONTENT_TEXTBOOK_READ |
|
| TextbooksController.findOne |
CONTENT_TEXTBOOK_READ |
|
| TextbooksController.update |
CONTENT_TEXTBOOK_UPDATE |
|
| TextbooksController.remove |
CONTENT_TEXTBOOK_DELETE |
|
| ChaptersController.* |
CONTENT_CHAPTER_{CREATE,READ,UPDATE,DELETE} |
|
| KnowledgePointsController.* |
CONTENT_KNOWLEDGE_POINT_{CREATE,READ,UPDATE,DELETE} |
|
| QuestionsController.* |
CONTENT_QUESTION_{CREATE,READ,UPDATE,DELETE} |
|
| QuestionsController.search |
CONTENT_QUESTION_READ |
新增 ES 检索 |
| 内部 RPC CreateQuestions (gRPC) |
CONTENT_QUESTION_CREATE |
AI 调用需带服务账号权限 |
6.2 错误码清单
| 错误码 |
HTTP |
触发条件 |
| CONTENT_VALIDATION_ERROR |
400 |
Zod 校验失败 / 题型非法 / 难度越界 |
| CONTENT_NOT_FOUND |
404 |
资源不存在 |
| CONTENT_PERMISSION_DENIED |
403 |
权限不足 |
| CONTENT_CONFLICT |
409 |
唯一约束冲突 / 状态机非法转换 |
| CONTENT_BUSINESS_ERROR |
422 |
业务规则违反(如循环依赖检测) |
| CONTENT_DATABASE_ERROR |
500 |
Drizzle 操作异常 |
| CONTENT_INTERNAL_ERROR |
500 |
未知异常 |
| CONTENT_NEO4J_UNAVAILABLE |
503 |
Neo4j 不可用且无降级路径 |
6.3 Logger 配置
- 库:pino(已在 logger.ts 实现)
- 日志级别:DEV_MODE 下
debug,生产 info
- 结构化字段:
requestId / userId / module / action / aggregateId / durationMs
- 采样:error 级别全量,warn 级别 100%,info 级别 10%
6.4 Metrics 指标清单
| 指标名 |
类型 |
标签 |
说明 |
content_http_requests_total |
Counter |
method/route/status_code |
HTTP 请求总数 |
content_http_request_duration_seconds |
Histogram |
method/route |
HTTP 请求延迟 |
content_grpc_requests_total |
Counter |
rpc_method/status |
gRPC 调用总数 |
content_db_query_duration_seconds |
Histogram |
table/operation |
DB 查询延迟 |
content_neo4j_query_duration_seconds |
Histogram |
query_type |
Neo4j 查询延迟 |
content_es_index_duration_seconds |
Histogram |
index |
ES 索引延迟 |
content_outbox_pending_count |
Gauge |
— |
Outbox 待投递事件数 |
content_outbox_publish_total |
Counter |
topic/status |
Outbox 投递总数 |
content_outbox_retry_count |
Counter |
topic |
Outbox 重试次数 |
content_kafka_consumer_lag |
Gauge |
topic |
Kafka 消费滞后 |
6.5 Tracer 配置
- 库:@opentelemetry/sdk-node + auto-instrumentations
- 采样率:DEV_MODE 100%,生产 10%(尾部采样可后续引入)
- 服务名:
content
- OTLP endpoint:
OTEL_EXPORTER_OTLP_ENDPOINT env 注入
6.6 健康检查
/healthz(liveness)
不依赖任何外部资源,仅返回进程存活状态。
/readyz(readiness · 多依赖检查)
判定规则:
- DB 不可用 →
status: down(不可接受流量)
- Neo4j 不可用 →
status: degraded(可接受流量,图谱查询降级)
- ES 未配置 →
skipped(不参与判定)
- Kafka 不可用 →
status: degraded(写请求可降级到 MySQL + Outbox,最终一致)
6.7 优雅关闭顺序
7. 与其他模块的交互点
7.1 跨模块交互矩阵
| 方向 |
对方服务 |
协议 |
接口/事件 |
用途 |
状态 |
| 被调用 |
teacher-bff |
HTTP REST |
GET /textbooks, GET /knowledge-points/* |
教师查教材/图谱 |
✅ 已实现 |
| 被调用 |
student-bff |
HTTP REST |
GET /textbooks, GET /knowledge-points/:id/prerequisites |
学生查学习路径 |
P3+ |
| 被调用 |
ai |
gRPC |
KnowledgeGraphService.GetPrerequisites |
AI 出题查询知识点 |
P5 待实现 gRPC |
| 被调用 |
ai |
gRPC |
QuestionService.CreateQuestion / BatchCreateQuestions |
AI 生成题目入库 |
P5 阻塞 · 待补 proto |
| 被调用 |
teacher-bff |
HTTP REST |
GET /questions/search |
教师检索题库 |
P5(ES 引入后) |
| 消费 |
core-edu |
Kafka |
edu.teaching.exam.published 等 |
联动题目状态 |
待 ai03 确认 topic |
| 发布 |
— |
Kafka |
edu.content.*(11 类事件,见 §5.1) |
通知 data-ana / ai / Neo4j 同步 worker / ES 同步 worker |
P4 实现 |
7.2 跨模块契约一致性提请(coord 仲裁)
| # |
提请内容 |
阻塞性 |
备注 |
| P1 |
content.proto 需补 QuestionService RPC(CreateQuestion/BatchCreateQuestions/Update/Delete/Get/List) |
🔴 阻塞 P5 AI 出题 |
提请 coord 修改 shared-proto |
| P2 |
content.proto 需补 ChapterService RPC |
🟡 阻塞 gRPC 完整契约 |
|
| P3 |
content.proto 需补 Update/Delete/分页字段(Textbook/KnowledgeGraph) |
🟡 阻塞 gRPC 完整契约 |
|
| P4 |
events.proto 需追加 KnowledgePointEvent / QuestionEvent message(含 source/createdBy/status 字段) |
🟡 content 发事件需明确 payload schema |
|
| P5 |
core-edu 教学内容失效事件 topic 待 ai03 设计确认 |
🟢 content 消费可选 |
|
| P6 |
DB 连接模式统一:const db vs getDb(),known-issues 自身描述矛盾 |
🟡 测试 mock 影响 |
建议统一为 getDb() 函数式(对齐 classes 黄金模板) |
| P7 |
ID 生成策略统一:cuid2 vs randomUUID |
🟡 可读性 / 排序性能 |
建议统一为 cuid2(对齐 classes 黄金模板) |
| P8 |
order 字段命名冲突(TS reserved word)→ DB 列名 order_num,TS 字段名 order |
✅ 已实现 |
仅记录 |
8. 演进路线
8.1 P4 阶段(当前)
目标:CRUD + 知识图谱 + Outbox + Kafka 事件发布
交付物:
- 4 张 MySQL 表(含补齐的时间戳、新增 status/source/created_by/metadata 字段)
- content_outbox_events 表 + Outbox Publisher worker
- Kafka producer(idempotent + transactionalId)
- Neo4j Sync Worker(消费 content 自身事件异步同步图谱)
- /readyz 多依赖检查
- ZodError 在 GlobalErrorFilter 特殊处理(返回 400)
- 测试覆盖率 ≥ 60%(Repository/Service 单元测试)
8.2 P5 阶段
目标:ES 全文检索 + gRPC 完整契约 + AI 出题入库
交付物:
- ES 索引 mapping + ensureIndex + 数据同步 consumer
- QuestionService gRPC controller(含 BatchCreateQuestions,对齐 004 §9.3)
- ChapterService / Update/Delete RPC
- 题库全文检索 API(GET /questions/search)
- 检索延迟 < 200ms(P5 退出标准)
- 测试覆盖率 ≥ 80%
8.3 P6+ 长远演进(架构预留点)
| 演进方向 |
当前架构预留 |
触发条件 |
| AI 辅助出题工作流 |
Question.source=ai_generated + status=pending_review 状态机 + createdBy 字段 |
P5 AI 服务上线后 |
| 知识图谱可视化 |
Neo4j 节点属性可扩展 + Cypher 查询预留(深度 1..10) |
教师端图谱可视化需求 |
| 教材版本管理 |
Textbook.version 字段 + 状态机 |
多版本教材并行场景 |
| 跨租户内容共享 |
tenant_id 字段预留 |
SaaS 多学校部署 |
| 多模态内容 |
metadata jsonb 字段(可存视频 URL/音频/AR 资源) |
多媒体教学需求 |
| 个性化学习路径推荐 |
GetLearningPath RPC + LearningPath message |
与 data-ana 配合实现 |
| 内容合规审核工作流 |
status=pending_review / rejected 状态机 + 审核日志表 |
教育合规要求 |
| 国际化内容 |
metadata jsonb 可存多语言字段 |
多语言教学场景 |
| 图神经网络分析 |
Neo4j GDS 库预留 |
学情深度分析需求 |
8.4 与黄金模板对齐
content 在 P4 完成后,应将以下模式回写到 classes 黄金模板(known-issues §2.2 提及"Outbox/CDC/长连接模式回写"):
- Outbox 表 schema + Publisher worker 模式
- 多依赖 /readyz 检查模式
- Neo4j 异步同步模式
- 状态机字段(status)设计模式
9. 风险与假设
9.1 技术风险
| # |
风险 |
影响 |
缓解措施 |
| R1 |
Neo4j 与 MySQL 双向一致性问题 |
知识图谱与 DB 不一致 |
Outbox 事件驱动同步 + 幂等去重 + 最终一致 |
| R2 |
ES 索引重建期间检索不可用 |
题库检索中断 |
alias 切换模式(双索引蓝绿) |
| R3 |
AI 批量出题 CreateQuestions 高并发 |
DB 写入瓶颈 |
BatchCreateQuestions 限制 batch_size ≤ 100 + 异步队列 |
| R4 |
Kafka 消费滞后导致 Neo4j/ES 同步延迟 |
图谱/检索数据陈旧 |
监控 consumer lag + 报警阈值 |
| R5 |
知识点循环依赖(A → B → A) |
GetPrerequisites 死循环 |
Neo4j 查询限定深度 1..5 + 应用层 DFS 检测 |
| R6 |
循环依赖 Outbox 表无限增长 |
DB 空间耗尽 |
SENT 状态 7 天后归档到冷库 |
| R7 |
gRPC 与 REST 双契约维护成本 |
接口漂移 |
proto 单一源 + 自动生成 DTO + 契约测试 |
9.2 假设
| # |
假设 |
依赖 |
Fallback |
| A1 |
core-edu 会发布 edu.teaching.content.invalidated 类事件 |
ai03 设计确认 |
content 不消费此事件,仅依赖管理端手动归档 |
| A2 |
IAM 提供学科字典 / 年级字典接口 |
ai06 设计确认 |
content 维护本地 subject_id/grade_id 字符串字典 |
| A3 |
Neo4j 5.x 可用 |
infra 部署 |
NEO4J_URL 未配置时 driver=null,图谱查询返回 503 |
| A4 |
ES 8.x 可用(P5+) |
infra 部署 |
ES_URL 未配置时 esClient=null,检索降级到 MySQL LIKE |
| A5 |
Kafka 集群可用 |
infra 部署 |
Outbox Publisher 失败时重试,最终一致 |
| A6 |
AI 服务调用 CreateQuestions 时携带服务账号 |
ai12 设计确认 |
PermissionGuard 校验服务账号权限点 CONTENT_QUESTION_CREATE |
9.3 待 coord 仲裁项
- content Outbox 启用时机:P4 还是 P5?(按 004 §12.2 强制条款,content 发事件必须 Outbox,建议 P4 启用)
- content ES 启用时机:P4 还是 P5?(pending-features 注明 P5,建议 P5 启用)
- content.proto proto 包名:保持
next_edu_cloud.content.v1(已仲裁)
- DB 连接模式统一:const db vs getDb(),建议统一为 getDb()
- ID 策略统一:cuid2 vs randomUUID,建议统一为 cuid2
- QuestionService proto 是否在 P4 阶段补齐:建议 P4 提前补齐,避免 P5 阻塞
10. 实施计划(建议)
10.1 P4 阶段任务拆分
| # |
任务 |
优先级 |
预估文件改动 |
| T1 |
补齐 chapters / knowledge_points 时间戳字段 |
P0 |
schema 迁移 + 重新生成 Drizzle types |
| T2 |
questions 表新增 status / source / created_by / metadata 字段 |
P0 |
schema 迁移 |
| T3 |
实现 content_outbox_events 表 + Outbox Publisher worker |
P0 |
新建 shared/outbox/ 目录 |
| T4 |
实现 Kafka producer(idempotent + transactionalId) |
P0 |
新建 shared/kafka/producer.ts |
| T5 |
实现 Neo4j Sync Worker(消费 content 自身事件异步同步) |
P0 |
新建 shared/sync/neo4j-sync.worker.ts |
| T6 |
重构 knowledge-points.service.ts:移除同步写 Neo4j,改为发事件 |
P0 |
重构 service 层 |
| T7 |
完善 /readyz 多依赖检查 |
P1 |
修改 health.controller.ts |
| T8 |
ZodError 在 GlobalErrorFilter 特殊处理 |
P1 |
修改 global-error.filter.ts |
| T9 |
补齐 Repository 层(textbooks/questions) |
P1 |
抽象 repository |
| T10 |
DB 连接模式改 getDb() 函数式 |
P2 |
修改 database.ts |
| T11 |
ID 策略改 cuid2 |
P2 |
修改 service 层 |
| T12 |
补齐单元测试(Service/Repository) |
P2 |
新建 *.spec.ts |
| T13 |
修正 README(与实现对齐) |
P2 |
修改 README.md |
10.2 P5 阶段任务(前置)
| # |
任务 |
阻塞性 |
| F1 |
content.proto 补 QuestionService / ChapterService / Update/Delete RPC |
🔴 阻塞 AI 出题 |
| F2 |
实现 QuestionService gRPC controller |
🔴 阻塞 AI 出题 |
| F3 |
引入 @elastic/elasticsearch + 实现 ES mapping + ensureIndex |
🔴 阻塞检索 |
| F4 |
实现 ES Sync Worker(消费 content 事件同步索引) |
🔴 阻塞检索 |
| F5 |
实现 GET /questions/search 检索 API |
🔴 阻塞检索 |
| F6 |
实现 ChapterService / TextbookService Update/Delete gRPC controller |
🟡 |
11. 与黄金模板对齐 checklist
| 项 |
classes 黄金模板 |
content 当前 |
content 目标(P4 完成) |
| 权限装饰器 |
@RequirePermission 全覆盖 |
✅ 16 端点 |
✅ 保持 |
| 错误码前缀 |
CLASSES_* |
✅ CONTENT_* |
✅ 保持 |
| logger |
pino |
✅ |
✅ 保持 |
| metrics |
prom-client + /metrics |
✅ |
✅ 保持 |
| tracer |
OTel + auto-instrumentations |
✅ |
✅ 保持 |
| /healthz |
✅ |
✅ |
✅ 保持 |
| /readyz |
DB SELECT 1 |
⚠️ 仅 DB |
✅ 多依赖(DB/Neo4j/Kafka) |
| 优雅关闭 |
LifecycleService |
✅ |
✅ 统一到 LifecycleService |
| 测试覆盖率 |
60% |
0% |
≥ 60% |
| Dockerfile |
多阶段 |
✅ |
✅ 保持 |
| Zod 校验 |
schema.parse |
⚠️ 部分 |
✅ Controller 层全 parse + ZodError 400 |
| GlobalErrorFilter |
✅ |
✅ |
✅ + ZodError 分支 |
| DB 连接模式 |
getDb() 函数式 |
⚠️ const db |
✅ 改 getDb() |
| ID 生成 |
cuid2 |
⚠️ randomUUID |
✅ 改 cuid2 |
| Repository 抽象 |
✅ |
⚠️ 部分 |
✅ 全部抽象 |
| Outbox |
❌(黄金模板自身无) |
❌ |
✅ 新增 Outbox 模式(回写黄金模板) |
| 状态机字段 |
❌ |
❌ |
✅ status 字段 |
AI Agent: ai05 (content + msg)
Coordinator: coord-ai
Branch: 单仓库并行模式(直接 push main)