feat(content): docker 本地测试通过 + P5 ES 集成 + P6+ 审核工作流/可视化/可观测性
P5 ES 集成: - config/elasticsearch.ts: 惰性初始化 + ik_max_word→standard 回退 - shared/sync/es-sync.worker.ts: Kafka 消费 question 事件并索引 ES - questions search API: ES 优先, MySQL LIKE 降级 - ensureQuestionIndex() 幂等创建, IK 不可用回退 standard - main.ts: 启动 ensureQuestionIndex + esSyncWorker 生命周期管理 P6+ 审核工作流/可视化/可观测性: - Question 状态机: draft→pending_review→published→archived - 非法转换拦截 - 知识图谱可视化 API: Neo4j 优先 + MySQL 降级 - Cypher 返回标量避免 Node 包装对象问题 - 教材版本管理: GET /textbooks/versions + archive - 5 个 Prometheus 指标 + /readyz Outbox 积压检查 Docker 本地测试 (8 类全通过): - healthz/readyz (5 依赖 ok) - REST CRUD (textbook/chapter/kp/question) - ES 全文检索命中 - 审核工作流状态机 (合法/非法转换) - Outbox 事件驱动 (8 事件全 published) - Neo4j 同步 (KnowledgePoint 节点创建) - 可视化 (nodes/edges 正确) - Prometheus 指标 docs/nextstep.md: 上游 (MySQL/Neo4j/Kafka/Redis/ES/ai) + 下游 (teacher-bff/student-bff/parent-bff/data-ana/api-gateway/ai)
This commit is contained in:
@@ -2,19 +2,45 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
# 复制 workspace 根配置
|
||||||
COPY services/content/tsconfig.json services/content/nest-cli.json ./
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||||
COPY services/content/src ./src
|
|
||||||
RUN pnpm build
|
# 复制 shared-proto(proto 文件运行时需要)
|
||||||
|
COPY packages/shared-proto/package.json packages/shared-proto/
|
||||||
|
COPY packages/shared-proto/proto packages/shared-proto/proto
|
||||||
|
|
||||||
|
# 复制 content 服务
|
||||||
|
COPY services/content/package.json services/content/tsconfig.json services/content/nest-cli.json ./services/content/
|
||||||
|
COPY services/content/src ./services/content/src
|
||||||
|
|
||||||
|
# 安装依赖(不执行 scripts)
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
RUN cd services/content && pnpm build
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
|
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
# 复制 workspace 配置
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
|
||||||
COPY packages/shared-proto/proto/content.proto ./proto/content.proto
|
|
||||||
|
# 复制 shared-proto(运行时加载 proto 文件)
|
||||||
|
COPY packages/shared-proto/package.json packages/shared-proto/
|
||||||
|
COPY packages/shared-proto/proto packages/shared-proto/proto
|
||||||
|
|
||||||
|
# 复制 content 服务 package.json
|
||||||
|
COPY services/content/package.json ./services/content/
|
||||||
|
|
||||||
|
# 安装生产依赖
|
||||||
|
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/services/content/dist ./services/content/dist
|
||||||
|
|
||||||
EXPOSE 3005 50054
|
EXPOSE 3005 50054
|
||||||
|
WORKDIR /app/services/content
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
286
services/content/docs/nextstep.md
Normal file
286
services/content/docs/nextstep.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# content 下一步工作与上下游依赖
|
||||||
|
|
||||||
|
> 模块:content(内容域服务,Textbook/Chapter/KnowledgeGraph/Question 四 Service,端口 HTTP 3005 / gRPC 50054)
|
||||||
|
> 负责人:ai09
|
||||||
|
> 更新日期:2026-07-13(P4–P6+ 全部完成,Docker 本地测试通过)
|
||||||
|
> 关联文档:[02-architecture-design.md](./02-architecture-design.md)、[content_contract.md](../../../docs/architecture/issues/contracts/content_contract.md)、[content_workline.md](../../../docs/architecture/issues/worklines/content_workline.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 模块当前状态
|
||||||
|
|
||||||
|
content 模块已完成 P4–P6+ 全部批次,提供 Textbook / Chapter / KnowledgeGraph / Question 四个 Service 共 22 个 RPC,本地 Docker 镜像构建与运行验证通过(非 mock 数据),ES 全文检索、Neo4j 知识图谱、审核工作流状态机、Outbox 事件驱动全部联调通过。
|
||||||
|
|
||||||
|
### 1.1 服务能力概览
|
||||||
|
|
||||||
|
| Service | RPC 数量 | 核心能力 |
|
||||||
|
| --------------------- | -------- | --------------------------------------------------------------- |
|
||||||
|
| TextbookService | — | 教材 CRUD、教材树管理、归档、版本查询 |
|
||||||
|
| ChapterService | — | 章节 CRUD、章节目录树 |
|
||||||
|
| KnowledgeGraphService | — | 知识点 CRUD、知识图谱(Neo4j)、学习路径、前置依赖、可视化 |
|
||||||
|
| QuestionService | — | 题目 CRUD、ES 全文检索、审核工作流状态机、AI 批量出题(待联调) |
|
||||||
|
| **合计** | **22** | 4 Service |
|
||||||
|
|
||||||
|
### 1.2 P5 完成项(ES 集成)
|
||||||
|
|
||||||
|
| 验证项 | 状态 | 说明 |
|
||||||
|
| -------------------------------------- | ---- | -------------------------------------------------------------- |
|
||||||
|
| Elasticsearch 容器就绪 | ✅ | 本地 Docker edu-es 已就绪 |
|
||||||
|
| ES config 模块 | ✅ | `src/config/elasticsearch.ts`(惰性初始化 + ik→standard 回退) |
|
||||||
|
| ES Sync Worker(Kafka 消费 → ES 索引) | ✅ | `src/shared/sync/es-sync.worker.ts`(event_id 去重 + 软失败) |
|
||||||
|
| SearchQuestions API 接入 ES | ✅ | ES 优先,MySQL LIKE 降级 |
|
||||||
|
| content_questions 索引映射 | ✅ | `ensureQuestionIndex()` 幂等创建,ik 不可用时回退 standard |
|
||||||
|
| /readyz ES 索引存在性检查 | ✅ | degraded 而非 error |
|
||||||
|
|
||||||
|
### 1.3 P6+ 完成项
|
||||||
|
|
||||||
|
| 验证项 | 状态 | 说明 |
|
||||||
|
| ------------------------- | ---- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Question 审核工作流状态机 | ✅ | draft→pending_review→published/rejected→archived,非法转换拦截 |
|
||||||
|
| 知识图谱可视化 API | ✅ | `GET /knowledge-graph/visualization`,Neo4j 优先 + MySQL 降级 |
|
||||||
|
| 教材版本管理 | ✅ | `GET /textbooks/versions` + `POST /textbooks/:id/archive` |
|
||||||
|
| 可观测性硬化 | ✅ | 5 个 Prometheus 指标(outbox_pending / neo4j_lag / es_lag / search_total / search_latency) |
|
||||||
|
| /readyz Outbox 积压检查 | ✅ | pending > 100 → degraded |
|
||||||
|
|
||||||
|
### 1.4 本地 Docker 验证结果(2026-07-13)
|
||||||
|
|
||||||
|
测试环境:本地 Docker(edu-mysql + edu-neo4j + edu-es + edu-kafka + edu-redis + edu-content-test,均接入 `edu-full_default` 网络)
|
||||||
|
|
||||||
|
```
|
||||||
|
镜像:edu/content:test
|
||||||
|
容器:edu-content-test(DEV_MODE=true)
|
||||||
|
|
||||||
|
测试 1:健康检查
|
||||||
|
GET /healthz → {"status":"ok","service":"content"} ✅
|
||||||
|
GET /readyz → {"status":"ok","dependencies":[mysql:ok, neo4j:ok, kafka:ok, outbox:ok(0), elasticsearch:ok]} ✅
|
||||||
|
|
||||||
|
测试 2:REST CRUD(全部通过)
|
||||||
|
POST /textbooks → 创建教材 ✅ (id=l9oo0iycnxlguqvhskjhrksa)
|
||||||
|
POST /chapters → 创建章节 ✅ (id=qdry24sm4ni2lrdf0fe7is2k)
|
||||||
|
POST /knowledge-points → 创建知识点 ✅ (id=tuuv90gloo7kf2x1al0b1b6g)
|
||||||
|
POST /questions → 创建题目 ✅ (id=ellr9gi1mnb9ir7ifxukhixs)
|
||||||
|
GET /questions/:id → 读取题目 ✅ (status=draft)
|
||||||
|
GET /questions?knowledgePointId=... → 列表查询 ✅ (count=1)
|
||||||
|
|
||||||
|
测试 3:ES 全文检索
|
||||||
|
GET /questions/search?q=3 → total=1 ✅ (ES 索引命中)
|
||||||
|
GET /questions/search?q=苹果 → total=0 (standard 分析器中文按字切分,IK 插件可用时改善)
|
||||||
|
|
||||||
|
测试 4:审核工作流状态机
|
||||||
|
POST /questions/:id/submit-review → draft→pending_review ✅
|
||||||
|
POST /questions/:id/approve → pending_review→published ✅
|
||||||
|
POST /questions/:id/archive → published→archived ✅
|
||||||
|
POST /questions/:id/approve → 非法转换正确拒绝 ✅ (CONTENT_VALIDATION_ERROR)
|
||||||
|
|
||||||
|
测试 5:Outbox 事件驱动
|
||||||
|
content_outbox_events 表 8 条事件,全部 status=published, retry_count=0 ✅
|
||||||
|
事件类型:textbook.created, chapter.created, knowledge_point.created,
|
||||||
|
question.created, question.updated×2, question.published, textbook.archived
|
||||||
|
|
||||||
|
测试 6:Neo4j 知识图谱同步
|
||||||
|
Neo4j KnowledgePoint 节点已创建 ✅ (id=tuuv90gloo7kf2x1al0b1b6g, difficulty=2)
|
||||||
|
GET /knowledge-graph/visualization → nodes=1, edges=0 ✅
|
||||||
|
|
||||||
|
测试 7:教材归档
|
||||||
|
POST /textbooks/:id/archive → status=archived ✅
|
||||||
|
|
||||||
|
测试 8:Prometheus 指标
|
||||||
|
GET /metrics → content_outbox_pending_total=0, content_neo4j_sync_lag_ms=0,
|
||||||
|
content_es_sync_lag_ms=0, content_question_search_total{source="es"} ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 上游依赖(content 依赖谁)
|
||||||
|
|
||||||
|
### 2.1 MySQL(基础设施)— P0 必需
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| -------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| 端点 | `mysql://edu-mysql:3306/next_edu_cloud` |
|
||||||
|
| 用途 | 主数据存储(textbooks / chapters / knowledge_points / questions / content_outbox_events 表) |
|
||||||
|
| 状态 | ✅ 本地 Docker edu-mysql 已就绪 |
|
||||||
|
| 环境变量 | `DATABASE_URL` |
|
||||||
|
|
||||||
|
### 2.2 Neo4j(基础设施)— P0 必需
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| -------- | ---------------------------------------------------------- |
|
||||||
|
| 端点 | `bolt://edu-neo4j:7687` |
|
||||||
|
| 用途 | 知识图谱存储(KnowledgePoint 节点 + PREREQUISITE_OF 关系) |
|
||||||
|
| 状态 | ✅ 本地 Docker edu-neo4j 已就绪 |
|
||||||
|
| 环境变量 | `NEO4J_URL`、`NEO4J_PASSWORD` |
|
||||||
|
|
||||||
|
### 2.3 Kafka(基础设施)— P0 必需
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| -------- | ------------------------------------------------------------------------------- |
|
||||||
|
| 端点 | `kafka:29092` |
|
||||||
|
| 用途 | Outbox 事件发布(4 个聚合 topic)+ Neo4j Sync Worker 消费 + ES Sync Worker 消费 |
|
||||||
|
| 状态 | ✅ 本地 Docker edu-kafka 已就绪 |
|
||||||
|
| 环境变量 | `KAFKA_BROKERS` |
|
||||||
|
|
||||||
|
### 2.4 Redis(基础设施)— P1 可选
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| -------- | ---------------------------------------------------------------- |
|
||||||
|
| 端点 | `redis://edu-redis:6379` |
|
||||||
|
| 用途 | 缓存(教材树 / 知识点树读多写少场景),env.ts 已预留 `REDIS_URL` |
|
||||||
|
| 状态 | ✅ 本地 Docker edu-redis 已就绪 |
|
||||||
|
| 环境变量 | `REDIS_URL` |
|
||||||
|
|
||||||
|
### 2.5 Elasticsearch(基础设施)— P5 必需
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| -------- | ------------------------------------------ |
|
||||||
|
| 端点 | `http://edu-es:9200` |
|
||||||
|
| 用途 | 题目全文检索(content_questions 索引) |
|
||||||
|
| 状态 | ✅ 本地 Docker edu-es 已就绪(profile p5) |
|
||||||
|
| 环境变量 | `ES_URL` |
|
||||||
|
|
||||||
|
### 2.6 ai 服务(ai12 负责)— P5 联调
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ------------------------------ | -------------------------------------------------------- | ----------------- |
|
||||||
|
| 1 | gRPC `GenerateQuestion` :50058 | AI 批量出题(QuestionService.BatchCreateQuestions 联调) | ⏳ 待 ai 服务就绪 |
|
||||||
|
|
||||||
|
**关键环境变量**:
|
||||||
|
|
||||||
|
- `AI_GRPC_TARGET=ai:50058`(待联调阶段配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 下游依赖(谁依赖 content)
|
||||||
|
|
||||||
|
### 3.1 teacher-bff(ai03 负责)— gRPC :50054
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | --------------------------------------------------- | -------------------- | ---------------------- |
|
||||||
|
| 1 | gRPC `GetLearningPath(studentId, subjectId)` :50054 | `knowledgePath` 查询 | ✅ content gRPC 已就绪 |
|
||||||
|
| 2 | gRPC `ListTextbooks(subjectId, gradeId)` :50054 | 教材列表 | ✅ content gRPC 已就绪 |
|
||||||
|
| 3 | gRPC `ListChapters(textbookId)` :50054 | 章节目录 | ✅ content gRPC 已就绪 |
|
||||||
|
|
||||||
|
### 3.2 student-bff(ai04 负责)— gRPC :50054
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ---------------------------------- | ------------ | ----------------------------------------- |
|
||||||
|
| 1 | gRPC `ListTextbooks` :50054 | 学生浏览教材 | ✅ content gRPC 已就绪 |
|
||||||
|
| 2 | gRPC `ListChapters` :50054 | 学生浏览章节 | ✅ content gRPC 已就绪 |
|
||||||
|
| 3 | gRPC `GetLearningPath` :50054 | 学生学习路径 | ✅ content gRPC 已就绪 |
|
||||||
|
| 4 | gRPC `SearchQuestions` :50054 | 题目检索 | ✅ content ES 已就绪,待 student-bff 联调 |
|
||||||
|
| 5 | gRPC `BatchCreateQuestions` :50054 | AI 出题 | ⏳ 待 ai 服务就绪后联调 |
|
||||||
|
|
||||||
|
### 3.3 parent-bff(ai05 负责)— gRPC :50054
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ------------------------------ | -------------------- | ---------------------- |
|
||||||
|
| 1 | gRPC `ListTextbooks` :50054 | 家长查看孩子教材 | ✅ content gRPC 已就绪 |
|
||||||
|
| 2 | gRPC `ListChapters` :50054 | 家长查看孩子章节 | ✅ content gRPC 已就绪 |
|
||||||
|
| 3 | gRPC `GetPrerequisites` :50054 | 孩子学习路径前置依赖 | ✅ content gRPC 已就绪 |
|
||||||
|
| 4 | gRPC `GetLearningPath` :50054 | 孩子学习路径 | ✅ content gRPC 已就绪 |
|
||||||
|
|
||||||
|
### 3.4 data-ana(ai11 负责)— Kafka 消费
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ----------------------------------------------- | ------------------ | ----------------------- |
|
||||||
|
| 1 | 消费 topic `edu.content.knowledge_point.events` | 分析知识点难度分布 | ✅ content Kafka 已就绪 |
|
||||||
|
| 2 | 消费 topic `edu.content.question.events` | 题目来源统计 | ✅ content Kafka 已就绪 |
|
||||||
|
|
||||||
|
### 3.5 api-gateway(ai01 负责)— HTTP REST :3005
|
||||||
|
|
||||||
|
| 能力 | 配置 | 状态 |
|
||||||
|
| ----------------------------------- | -------------------- | ------------------------- |
|
||||||
|
| `/api/v1/textbooks` 反向代理 | → content:3005 | ✅ api-gateway 已配置路由 |
|
||||||
|
| `/api/v1/chapters` 反向代理 | → content:3005 | ✅ api-gateway 已配置路由 |
|
||||||
|
| `/api/v1/knowledge-points` 反向代理 | → content:3005 | ✅ api-gateway 已配置路由 |
|
||||||
|
| `/api/v1/questions` 反向代理 | → content:3005 | ✅ api-gateway 已配置路由 |
|
||||||
|
| `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已实现 |
|
||||||
|
|
||||||
|
### 3.6 ai 服务(ai12 负责)— Kafka 消费
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ---------------------------------------- | ----------------------- | ----------------------- |
|
||||||
|
| 1 | 消费 topic `edu.content.question.events` | AI 出题时获取知识点信息 | ✅ content Kafka 已就绪 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 环境变量清单
|
||||||
|
|
||||||
|
| 变量 | 必填 | 示例值 | 说明 |
|
||||||
|
| ----------------------------- | ---- | ---------------------------------------------------- | ------------------ |
|
||||||
|
| `PORT` | 是 | `3005` | HTTP 监听端口 |
|
||||||
|
| `GRPC_PORT` | 是 | `50054` | gRPC 监听端口 |
|
||||||
|
| `DATABASE_URL` | 是 | `mysql://edu:changeme@edu-mysql:3306/next_edu_cloud` | MySQL 连接 |
|
||||||
|
| `REDIS_URL` | 否 | `redis://edu-redis:6379` | Redis 缓存 |
|
||||||
|
| `NEO4J_URL` | 否 | `bolt://edu-neo4j:7687` | Neo4j 连接 |
|
||||||
|
| `NEO4J_PASSWORD` | 否 | `changeme` | Neo4j 密码 |
|
||||||
|
| `ES_URL` | 否 | `http://edu-es:9200` | Elasticsearch 连接 |
|
||||||
|
| `KAFKA_BROKERS` | 是 | `kafka:29092` | Kafka broker |
|
||||||
|
| `JWT_SECRET` | 否 | — | JWT 密钥 |
|
||||||
|
| `DEV_MODE` | 否 | `false` | 开发模式 |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | 否 | `http://otel-collector:4318` | OTLP 端点 |
|
||||||
|
| `LOG_LEVEL` | 否 | `info` | 日志级别 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 剩余工作
|
||||||
|
|
||||||
|
| # | 工作项 | 阶段 | 状态 |
|
||||||
|
| --- | ---------------------------------------------- | ---- | ----------------- |
|
||||||
|
| 1 | ES 集成(config + sync worker + search API) | P5 | ✅ 完成 |
|
||||||
|
| 2 | Question 审核工作流状态机 | P6+ | ✅ 完成 |
|
||||||
|
| 3 | 知识图谱可视化 API | P6+ | ✅ 完成 |
|
||||||
|
| 4 | 教材版本管理 | P6+ | ✅ 完成 |
|
||||||
|
| 5 | 可观测性硬化(Prometheus 指标 + readyz 检查) | P6+ | ✅ 完成 |
|
||||||
|
| 6 | AI 批量出题联调(BatchCreateQuestions + ai12) | 联调 | ⏳ 待 ai 服务就绪 |
|
||||||
|
|
||||||
|
content 模块自身功能已全部完成,无剩余开发任务。唯一待办为 ai 服务(ai12)就绪后的 AI 出题联调。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Docker 本地测试
|
||||||
|
|
||||||
|
### 6.1 镜像构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t edu/content:test -f services/content/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 容器启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name edu-content-test \
|
||||||
|
--network edu-full_default \
|
||||||
|
-p 3005:3005 -p 50054:50054 \
|
||||||
|
-e DATABASE_URL=mysql://edu:changeme@edu-mysql:3306/next_edu_cloud \
|
||||||
|
-e NEO4J_URL=bolt://edu-neo4j:7687 \
|
||||||
|
-e NEO4J_PASSWORD=changeme \
|
||||||
|
-e ES_URL=http://edu-es:9200 \
|
||||||
|
-e KAFKA_BROKERS=kafka:29092 \
|
||||||
|
-e REDIS_URL=redis://edu-redis:6379 \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e DEV_MODE=true \
|
||||||
|
edu/content:test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 关键文件路径
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| -------------------------------------------------------- | ----------------------------- |
|
||||||
|
| `packages/shared-proto/proto/content.proto` | gRPC 契约(22 RPC) |
|
||||||
|
| `packages/shared-proto/proto/events.proto` | 事件契约(4 content message) |
|
||||||
|
| `services/content/src/main.ts` | 服务入口 |
|
||||||
|
| `services/content/src/app.module.ts` | NestJS 模块注册 |
|
||||||
|
| `services/content/src/grpc/` | 4 个 gRPC controller |
|
||||||
|
| `services/content/src/shared/outbox/` | Outbox 模式实现 |
|
||||||
|
| `services/content/src/shared/sync/neo4j-sync.worker.ts` | Neo4j 异步同步 |
|
||||||
|
| `services/content/Dockerfile` | Docker 构建 |
|
||||||
|
| `docs/architecture/issues/contracts/content_contract.md` | 契约文档 |
|
||||||
|
| `docs/architecture/issues/worklines/content_workline.md` | 工作排期 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本文件由 ai09 维护,上游/下游工作项请各负责 AI 完成后通知 ai09 更新状态。**
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
|
||||||
"@opentelemetry/sdk-node": "^0.55.0",
|
"@opentelemetry/sdk-node": "^0.55.0",
|
||||||
|
"@elastic/elasticsearch": "^8.13.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"drizzle-orm": "^0.31.0",
|
"drizzle-orm": "^0.31.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
|||||||
127
services/content/src/config/elasticsearch.test.ts
Normal file
127
services/content/src/config/elasticsearch.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ES Client mock 工厂:每次返回新的 mock 函数,避免跨用例污染
|
||||||
|
function createMockClient() {
|
||||||
|
const mockIndicesExists = vi.fn();
|
||||||
|
const mockIndicesCreate = vi.fn();
|
||||||
|
const mockClose = vi.fn();
|
||||||
|
const MockClient = vi.fn().mockImplementation(() => ({
|
||||||
|
indices: {
|
||||||
|
exists: mockIndicesExists,
|
||||||
|
create: mockIndicesCreate,
|
||||||
|
},
|
||||||
|
close: mockClose,
|
||||||
|
}));
|
||||||
|
return { MockClient, mockIndicesExists, mockIndicesCreate, mockClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loggerMock = {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("elasticsearch config — ES 未配置", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env.ES_URL 未配置时 getEsClient 返回 null", async () => {
|
||||||
|
vi.doMock("./env.js", () => ({ env: { ES_URL: undefined } }));
|
||||||
|
vi.doMock("../shared/observability/logger.js", () => ({
|
||||||
|
logger: loggerMock,
|
||||||
|
}));
|
||||||
|
const { Client: MockClientCtor } = await import("@elastic/elasticsearch");
|
||||||
|
expect(MockClientCtor).toBeDefined();
|
||||||
|
|
||||||
|
const { getEsClient } = await import("./elasticsearch.js");
|
||||||
|
expect(getEsClient()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 未配置时 ensureQuestionIndex 为空操作", async () => {
|
||||||
|
vi.doMock("./env.js", () => ({ env: { ES_URL: undefined } }));
|
||||||
|
vi.doMock("../shared/observability/logger.js", () => ({
|
||||||
|
logger: loggerMock,
|
||||||
|
}));
|
||||||
|
const { ensureQuestionIndex } = await import("./elasticsearch.js");
|
||||||
|
await expect(ensureQuestionIndex()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("elasticsearch config — ES 已配置", () => {
|
||||||
|
let mockClient: ReturnType<typeof createMockClient>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockClient = createMockClient();
|
||||||
|
vi.doMock("@elastic/elasticsearch", () => ({
|
||||||
|
Client: mockClient.MockClient,
|
||||||
|
}));
|
||||||
|
vi.doMock("./env.js", () => ({
|
||||||
|
env: { ES_URL: "http://localhost:9200" },
|
||||||
|
}));
|
||||||
|
vi.doMock("../shared/observability/logger.js", () => ({
|
||||||
|
logger: loggerMock,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env.ES_URL 配置时 getEsClient 返回 client 实例", async () => {
|
||||||
|
const { getEsClient } = await import("./elasticsearch.js");
|
||||||
|
const client = getEsClient();
|
||||||
|
expect(client).not.toBeNull();
|
||||||
|
expect(mockClient.MockClient).toHaveBeenCalledWith({
|
||||||
|
node: "http://localhost:9200",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("索引不存在时 ensureQuestionIndex 创建索引", async () => {
|
||||||
|
mockClient.mockIndicesExists.mockResolvedValue(false);
|
||||||
|
const { ensureQuestionIndex, QUESTION_INDEX_NAME } =
|
||||||
|
await import("./elasticsearch.js");
|
||||||
|
await ensureQuestionIndex();
|
||||||
|
|
||||||
|
expect(mockClient.mockIndicesExists).toHaveBeenCalledWith({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
});
|
||||||
|
expect(mockClient.mockIndicesCreate).toHaveBeenCalledTimes(1);
|
||||||
|
const createCall = mockClient.mockIndicesCreate.mock.calls[0]?.[0];
|
||||||
|
expect(createCall?.index).toBe(QUESTION_INDEX_NAME);
|
||||||
|
// 验证 mapping 关键字段
|
||||||
|
const props = createCall?.mappings?.properties;
|
||||||
|
expect(props?.question_id?.type).toBe("keyword");
|
||||||
|
expect(props?.content?.analyzer).toBe("ik_max_word");
|
||||||
|
expect(props?.content?.search_analyzer).toBe("ik_smart");
|
||||||
|
expect(props?.answer?.analyzer).toBe("ik_max_word");
|
||||||
|
expect(props?.difficulty?.type).toBe("integer");
|
||||||
|
expect(props?.created_at?.type).toBe("date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("索引已存在时 ensureQuestionIndex 跳过创建", async () => {
|
||||||
|
mockClient.mockIndicesExists.mockResolvedValue(true);
|
||||||
|
const { ensureQuestionIndex } = await import("./elasticsearch.js");
|
||||||
|
await ensureQuestionIndex();
|
||||||
|
|
||||||
|
expect(mockClient.mockIndicesExists).toHaveBeenCalled();
|
||||||
|
expect(mockClient.mockIndicesCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 查询抛错时 ensureQuestionIndex 软失败(不抛出)", async () => {
|
||||||
|
mockClient.mockIndicesExists.mockRejectedValue(
|
||||||
|
new Error("connection refused"),
|
||||||
|
);
|
||||||
|
const { ensureQuestionIndex } = await import("./elasticsearch.js");
|
||||||
|
await expect(ensureQuestionIndex()).resolves.not.toThrow();
|
||||||
|
expect(loggerMock.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ensureQuestionIndex 是幂等的(多次调用安全)", async () => {
|
||||||
|
mockClient.mockIndicesExists.mockResolvedValue(true);
|
||||||
|
const { ensureQuestionIndex } = await import("./elasticsearch.js");
|
||||||
|
await ensureQuestionIndex();
|
||||||
|
await ensureQuestionIndex();
|
||||||
|
expect(mockClient.mockIndicesCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
services/content/src/config/elasticsearch.ts
Normal file
129
services/content/src/config/elasticsearch.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Client } from "@elastic/elasticsearch";
|
||||||
|
import type { Client as EsClient } from "@elastic/elasticsearch";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { logger } from "../shared/observability/logger.js";
|
||||||
|
|
||||||
|
// ES Client 创建为惰性初始化:未配置 ES_URL 时 client 保持 null,
|
||||||
|
// 服务仍可正常启动。所有依赖 ES 的功能(sync worker / 搜索)在
|
||||||
|
// client 为 null 时优雅降级(跳过同步 / 回退 MySQL LIKE 查询),
|
||||||
|
// 不会阻塞主流程。
|
||||||
|
let client: EsClient | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (env.ES_URL) {
|
||||||
|
client = new Client({ node: env.ES_URL });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"Elasticsearch client init failed, running without ES:",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QUESTION_INDEX_NAME = "content_questions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ES Client。未配置 ES_URL 时返回 null。
|
||||||
|
*/
|
||||||
|
export function getEsClient(): EsClient | null {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等创建 content_questions 索引。
|
||||||
|
* 索引已存在时跳过;ES 不可用时跳过并记录 warn(软失败)。
|
||||||
|
*
|
||||||
|
* 中文全文检索优先使用 ik_max_word(索引侧细粒度分词)+
|
||||||
|
* ik_smart(查询侧粗粒度分词)插件;若 ES 集群未安装 analysis-ik,
|
||||||
|
* 自动回退到 standard 分析器(按字切分,准确度较低但功能可用)。
|
||||||
|
*/
|
||||||
|
export async function ensureQuestionIndex(): Promise<void> {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const exists = await client.indices.exists({ index: QUESTION_INDEX_NAME });
|
||||||
|
if (exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.indices.create({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
mappings: {
|
||||||
|
properties: {
|
||||||
|
question_id: { type: "keyword" },
|
||||||
|
knowledge_point_id: { type: "keyword" },
|
||||||
|
type: { type: "keyword" },
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
analyzer: "ik_max_word",
|
||||||
|
search_analyzer: "ik_smart",
|
||||||
|
},
|
||||||
|
answer: { type: "text", analyzer: "ik_max_word" },
|
||||||
|
explanation: { type: "text", analyzer: "ik_max_word" },
|
||||||
|
difficulty: { type: "integer" },
|
||||||
|
status: { type: "keyword" },
|
||||||
|
source: { type: "keyword" },
|
||||||
|
created_by: { type: "keyword" },
|
||||||
|
created_at: { type: "date" },
|
||||||
|
updated_at: { type: "date" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ index: QUESTION_INDEX_NAME, analyzer: "ik" },
|
||||||
|
"ES question index created",
|
||||||
|
);
|
||||||
|
} catch (ikErr) {
|
||||||
|
// ik 分析器不可用:回退到 standard 分析器
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
err: ikErr instanceof Error ? ikErr.message : String(ikErr),
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
},
|
||||||
|
"ik analyzer not available, falling back to standard analyzer",
|
||||||
|
);
|
||||||
|
await client.indices.create({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
mappings: {
|
||||||
|
properties: {
|
||||||
|
question_id: { type: "keyword" },
|
||||||
|
knowledge_point_id: { type: "keyword" },
|
||||||
|
type: { type: "keyword" },
|
||||||
|
content: { type: "text", analyzer: "standard" },
|
||||||
|
answer: { type: "text", analyzer: "standard" },
|
||||||
|
explanation: { type: "text", analyzer: "standard" },
|
||||||
|
difficulty: { type: "integer" },
|
||||||
|
status: { type: "keyword" },
|
||||||
|
source: { type: "keyword" },
|
||||||
|
created_by: { type: "keyword" },
|
||||||
|
created_at: { type: "date" },
|
||||||
|
updated_at: { type: "date" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ index: QUESTION_INDEX_NAME, analyzer: "standard" },
|
||||||
|
"ES question index created (standard fallback)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
},
|
||||||
|
"ensureQuestionIndex failed (ES may be unavailable), skipping",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 ES Client 连接。
|
||||||
|
*/
|
||||||
|
export async function closeEs(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ export const neo4jSyncConsumer = kafka.consumer({
|
|||||||
groupId: "content-neo4j-sync",
|
groupId: "content-neo4j-sync",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ES sync consumer:消费题目事件,异步索引到 Elasticsearch
|
||||||
|
export const esSyncConsumer = kafka.consumer({
|
||||||
|
groupId: "content-es-sync",
|
||||||
|
});
|
||||||
|
|
||||||
let producerIsConnected = false;
|
let producerIsConnected = false;
|
||||||
|
|
||||||
producer.on("producer.connect", () => {
|
producer.on("producer.connect", () => {
|
||||||
@@ -54,4 +59,9 @@ export async function disconnectKafka(): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await esSyncConsumer.disconnect();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
KnowledgePointsService,
|
KnowledgePointsService,
|
||||||
type PrerequisiteNode,
|
type PrerequisiteNode,
|
||||||
|
type VisualizationResult,
|
||||||
} from "./knowledge-points.service.js";
|
} from "./knowledge-points.service.js";
|
||||||
import type { KnowledgePoint } from "./knowledge-points.schema.js";
|
import type { KnowledgePoint } from "./knowledge-points.schema.js";
|
||||||
import {
|
import {
|
||||||
@@ -104,3 +106,21 @@ export class KnowledgePointsController {
|
|||||||
return { success: true, data: { success: true } };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.2: 知识图谱可视化——独立控制器,路由前缀 /knowledge-graph
|
||||||
|
@Controller("knowledge-graph")
|
||||||
|
export class KnowledgeGraphController {
|
||||||
|
constructor(private readonly service: KnowledgePointsService) {}
|
||||||
|
|
||||||
|
// GET /knowledge-graph/visualization?chapterId=xxx
|
||||||
|
// 返回 nodes[] + edges[](D3.js / vis.js 可消费格式)
|
||||||
|
// Neo4j 不可用时降级从 MySQL 查询节点(edges 为空)
|
||||||
|
@Get("visualization")
|
||||||
|
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
|
||||||
|
async visualization(
|
||||||
|
@Query("chapterId") chapterId?: string,
|
||||||
|
): Promise<{ success: true; data: VisualizationResult }> {
|
||||||
|
const data = await this.service.getVisualization(chapterId);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { KnowledgePointsController } from "./knowledge-points.controller.js";
|
import {
|
||||||
|
KnowledgePointsController,
|
||||||
|
KnowledgeGraphController,
|
||||||
|
} from "./knowledge-points.controller.js";
|
||||||
import { KnowledgePointsService } from "./knowledge-points.service.js";
|
import { KnowledgePointsService } from "./knowledge-points.service.js";
|
||||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
controllers: [KnowledgePointsController],
|
controllers: [KnowledgePointsController, KnowledgeGraphController],
|
||||||
providers: [KnowledgePointsService],
|
providers: [KnowledgePointsService],
|
||||||
exports: [KnowledgePointsService],
|
exports: [KnowledgePointsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class KnowledgePointsRepository {
|
|||||||
.where(eq(knowledgePoints.chapterId, chapterId));
|
.where(eq(knowledgePoints.chapterId, chapterId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.2: 可视化降级查询——返回全部知识点(无分页)
|
||||||
|
async findAll(): Promise<KnowledgePoint[]> {
|
||||||
|
return getDb().select().from(knowledgePoints);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: NewKnowledgePoint): Promise<void> {
|
async create(data: NewKnowledgePoint): Promise<void> {
|
||||||
await getDb().insert(knowledgePoints).values(data);
|
await getDb().insert(knowledgePoints).values(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ vi.mock("./knowledge-points.repository.js", () => ({
|
|||||||
knowledgePointsRepository: {
|
knowledgePointsRepository: {
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByChapterId: vi.fn(),
|
findByChapterId: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -146,4 +147,57 @@ describe("KnowledgePointsService", () => {
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// P6.2: 可视化测试
|
||||||
|
describe("getVisualization", () => {
|
||||||
|
it("Neo4j 不可用时降级从 MySQL 查询节点(edges 为空)", async () => {
|
||||||
|
const mockKps = [
|
||||||
|
{
|
||||||
|
id: "kp-1",
|
||||||
|
chapterId: "ch-1",
|
||||||
|
title: "KP1",
|
||||||
|
description: null,
|
||||||
|
difficulty: 3,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.mocked(knowledgePointsRepository.findAll).mockResolvedValue(mockKps);
|
||||||
|
|
||||||
|
const result = await service.getVisualization();
|
||||||
|
|
||||||
|
expect(result.nodes).toHaveLength(1);
|
||||||
|
expect(result.nodes[0]?.id).toBe("kp-1");
|
||||||
|
expect(result.nodes[0]?.title).toBe("KP1");
|
||||||
|
expect(result.nodes[0]?.difficulty).toBe(3);
|
||||||
|
expect(result.edges).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("传入 chapterId 时降级路径调用 findByChapterId", async () => {
|
||||||
|
const mockKps = [
|
||||||
|
{
|
||||||
|
id: "kp-1",
|
||||||
|
chapterId: "ch-1",
|
||||||
|
title: "KP1",
|
||||||
|
description: null,
|
||||||
|
difficulty: 3,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.mocked(knowledgePointsRepository.findByChapterId).mockResolvedValue(
|
||||||
|
mockKps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getVisualization("ch-1");
|
||||||
|
|
||||||
|
expect(knowledgePointsRepository.findByChapterId).toHaveBeenCalledWith(
|
||||||
|
"ch-1",
|
||||||
|
);
|
||||||
|
expect(result.nodes).toHaveLength(1);
|
||||||
|
expect(result.edges).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,24 @@ export interface PrerequisiteNode {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.2: 知识图谱可视化返回结构(D3.js / vis.js 可消费格式)
|
||||||
|
export interface VisualizationNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
difficulty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualizationEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisualizationResult {
|
||||||
|
nodes: VisualizationNode[];
|
||||||
|
edges: VisualizationEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KnowledgePointsService {
|
export class KnowledgePointsService {
|
||||||
private readonly logger = new Logger(KnowledgePointsService.name);
|
private readonly logger = new Logger(KnowledgePointsService.name);
|
||||||
@@ -188,4 +206,147 @@ export class KnowledgePointsService {
|
|||||||
{ prerequisite_id: prerequisiteId },
|
{ prerequisite_id: prerequisiteId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== P6.2: 知识图谱可视化 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识图谱可视化数据(nodes + edges)。
|
||||||
|
*
|
||||||
|
* 数据源策略:
|
||||||
|
* - Neo4j 可用:执行 Cypher 查询,返回节点 + PREREQUISITE_OF 关系
|
||||||
|
* - Neo4j 不可用:降级从 MySQL 查询知识点节点(edges 为空,
|
||||||
|
* 因 MySQL 不存储前置关系,关系数据仅存于 Neo4j 派生读模型)
|
||||||
|
*
|
||||||
|
* @param chapterId 可选章节 ID,提供时仅返回该章节下的知识点
|
||||||
|
*/
|
||||||
|
async getVisualization(chapterId?: string): Promise<VisualizationResult> {
|
||||||
|
const session = getNeo4jSession();
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
return await this.getVisualizationFromNeo4j(session, chapterId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Neo4j getVisualization failed, falling back to MySQL: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:仅返回 MySQL 中的知识点节点,edges 为空
|
||||||
|
return this.getVisualizationFromMysql(chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Neo4j 查询可视化数据。
|
||||||
|
* 1. 先查询所有 KnowledgePoint 节点(含孤立节点,确保可视化完整)
|
||||||
|
* 2. 再查询 PREREQUISITE_OF 关系构建 edges
|
||||||
|
* 按 chapterId 过滤(若提供)。
|
||||||
|
*
|
||||||
|
* 注意:Cypher 返回标量值(kp.id as id),而非整个 Node 对象。
|
||||||
|
* neo4j-driver 的 Node 对象属性在 .properties 下,直接 RETURN kp 会导致
|
||||||
|
* JS 侧 record.get("kp") 返回 Node 包装对象,无法直接 .id 访问。
|
||||||
|
*/
|
||||||
|
private async getVisualizationFromNeo4j(
|
||||||
|
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
|
||||||
|
chapterId?: string,
|
||||||
|
): Promise<VisualizationResult> {
|
||||||
|
// 1. 查询所有 KnowledgePoint 节点(返回标量,避免 Node 包装对象)
|
||||||
|
const nodeCypher = chapterId
|
||||||
|
? `MATCH (kp:KnowledgePoint) WHERE kp.chapter_id = $chapterId RETURN kp.id as id, kp.title as title, kp.difficulty as difficulty`
|
||||||
|
: `MATCH (kp:KnowledgePoint) RETURN kp.id as id, kp.title as title, kp.difficulty as difficulty`;
|
||||||
|
const nodeResult = await session.executeRead((tx) =>
|
||||||
|
tx.run(nodeCypher, chapterId ? { chapterId } : {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeMap = new Map<string, VisualizationNode>();
|
||||||
|
for (const record of nodeResult.records) {
|
||||||
|
const node = this.recordToVisualizationNode(record);
|
||||||
|
if (node) nodeMap.set(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询 PREREQUISITE_OF 关系(返回两端节点 ID 标量)
|
||||||
|
const edgeCypher = chapterId
|
||||||
|
? `MATCH (kp:KnowledgePoint)-[r:PREREQUISITE_OF]->(kp2:KnowledgePoint)
|
||||||
|
WHERE kp.chapter_id = $chapterId OR kp2.chapter_id = $chapterId
|
||||||
|
RETURN kp.id as sourceId, kp2.id as targetId`
|
||||||
|
: `MATCH (kp:KnowledgePoint)-[r:PREREQUISITE_OF]->(kp2:KnowledgePoint)
|
||||||
|
RETURN kp.id as sourceId, kp2.id as targetId`;
|
||||||
|
const edgeResult = await session.executeRead((tx) =>
|
||||||
|
tx.run(edgeCypher, chapterId ? { chapterId } : {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const edges: VisualizationEdge[] = [];
|
||||||
|
for (const record of edgeResult.records) {
|
||||||
|
const sourceId = this.getStringProp(record.toObject(), "sourceId");
|
||||||
|
const targetId = this.getStringProp(record.toObject(), "targetId");
|
||||||
|
if (sourceId && targetId) {
|
||||||
|
edges.push({
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
label: "PREREQUISITE_OF",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: Array.from(nodeMap.values()), edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Cypher 记录(含 id/title/difficulty 标量字段)转为 VisualizationNode。
|
||||||
|
* Neo4j Integer 类型的 difficulty 需特殊处理(toNumber() 或直接比较)。
|
||||||
|
*/
|
||||||
|
private recordToVisualizationNode(record: {
|
||||||
|
toObject: () => Record<string, unknown>;
|
||||||
|
}): VisualizationNode | null {
|
||||||
|
const obj = record.toObject();
|
||||||
|
const id = this.getStringProp(obj, "id");
|
||||||
|
if (!id) return null;
|
||||||
|
const rawDifficulty = obj.difficulty;
|
||||||
|
let difficulty = 3;
|
||||||
|
if (typeof rawDifficulty === "number") {
|
||||||
|
difficulty = rawDifficulty;
|
||||||
|
} else if (
|
||||||
|
rawDifficulty !== null &&
|
||||||
|
rawDifficulty !== undefined &&
|
||||||
|
typeof rawDifficulty === "object" &&
|
||||||
|
"toNumber" in (rawDifficulty as object)
|
||||||
|
) {
|
||||||
|
// neo4j-driver Integer 对象
|
||||||
|
difficulty = (rawDifficulty as { toNumber: () => number }).toNumber();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: this.getStringProp(obj, "title") ?? "",
|
||||||
|
difficulty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 降级路径:仅返回知识点节点,edges 为空。
|
||||||
|
* 前置关系仅存于 Neo4j 派生读模型,MySQL 主库不存储。
|
||||||
|
*/
|
||||||
|
private async getVisualizationFromMysql(
|
||||||
|
chapterId?: string,
|
||||||
|
): Promise<VisualizationResult> {
|
||||||
|
const kps = chapterId
|
||||||
|
? await knowledgePointsRepository.findByChapterId(chapterId)
|
||||||
|
: await knowledgePointsRepository.findAll();
|
||||||
|
|
||||||
|
const nodes: VisualizationNode[] = kps.map((kp) => ({
|
||||||
|
id: kp.id,
|
||||||
|
title: kp.title,
|
||||||
|
difficulty: kp.difficulty,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { nodes, edges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStringProp(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): string | undefined {
|
||||||
|
const v = obj[key];
|
||||||
|
return typeof v === "string" ? v : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { logger } from "./shared/observability/logger.js";
|
|||||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||||
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
||||||
import { neo4jSyncWorker } from "./shared/sync/neo4j-sync.worker.js";
|
import { neo4jSyncWorker } from "./shared/sync/neo4j-sync.worker.js";
|
||||||
|
import { esSyncWorker } from "./shared/sync/es-sync.worker.js";
|
||||||
|
import { ensureQuestionIndex, closeEs } from "./config/elasticsearch.js";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,15 +89,23 @@ async function bootstrap(): Promise<void> {
|
|||||||
// 启动 Outbox Publisher(轮询 pending 事件投递 Kafka)
|
// 启动 Outbox Publisher(轮询 pending 事件投递 Kafka)
|
||||||
await outboxPublisher.start();
|
await outboxPublisher.start();
|
||||||
|
|
||||||
|
// 创建/校验 ES 索引(幂等;ES 不可用时跳过)
|
||||||
|
await ensureQuestionIndex();
|
||||||
|
|
||||||
// 启动 Neo4j Sync Worker(消费 Kafka 事件异步同步图谱)
|
// 启动 Neo4j Sync Worker(消费 Kafka 事件异步同步图谱)
|
||||||
await neo4jSyncWorker.start();
|
await neo4jSyncWorker.start();
|
||||||
|
|
||||||
|
// 启动 ES Sync Worker(消费题目事件,异步索引到 Elasticsearch)
|
||||||
|
await esSyncWorker.start();
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
process.on("SIGTERM", async () => {
|
||||||
logger.info("SIGTERM received, shutting down gracefully...");
|
logger.info("SIGTERM received, shutting down gracefully...");
|
||||||
|
await esSyncWorker.stop();
|
||||||
await neo4jSyncWorker.stop();
|
await neo4jSyncWorker.stop();
|
||||||
await outboxPublisher.stop();
|
await outboxPublisher.stop();
|
||||||
await disconnectKafka();
|
await disconnectKafka();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
await closeEs();
|
||||||
await closeNeo4j();
|
await closeNeo4j();
|
||||||
await closeDb();
|
await closeDb();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
@@ -104,10 +114,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
process.on("SIGINT", async () => {
|
process.on("SIGINT", async () => {
|
||||||
logger.info("SIGINT received, shutting down gracefully...");
|
logger.info("SIGINT received, shutting down gracefully...");
|
||||||
|
await esSyncWorker.stop();
|
||||||
await neo4jSyncWorker.stop();
|
await neo4jSyncWorker.stop();
|
||||||
await outboxPublisher.stop();
|
await outboxPublisher.stop();
|
||||||
await disconnectKafka();
|
await disconnectKafka();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
await closeEs();
|
||||||
await closeNeo4j();
|
await closeNeo4j();
|
||||||
await closeDb();
|
await closeDb();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const mockService = {
|
|||||||
getQuestion: vi.fn(),
|
getQuestion: vi.fn(),
|
||||||
updateQuestion: vi.fn(),
|
updateQuestion: vi.fn(),
|
||||||
deleteQuestion: vi.fn(),
|
deleteQuestion: vi.fn(),
|
||||||
|
searchQuestions: vi.fn(),
|
||||||
|
submitForReview: vi.fn(),
|
||||||
|
approveQuestion: vi.fn(),
|
||||||
|
rejectQuestion: vi.fn(),
|
||||||
|
archiveQuestion: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("QuestionsController", () => {
|
describe("QuestionsController", () => {
|
||||||
@@ -71,6 +76,19 @@ describe("QuestionsController", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
it("should call service.searchQuestions and return paginated data", async () => {
|
||||||
|
mockService.searchQuestions.mockResolvedValue({
|
||||||
|
items: [{ id: "q-1" }],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
const result = await controller.search({ q: "math" });
|
||||||
|
expect(mockService.searchQuestions).toHaveBeenCalled();
|
||||||
|
expect(result.data.total).toBe(1);
|
||||||
|
expect(result.data.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getById", () => {
|
describe("getById", () => {
|
||||||
it("should call service.getQuestion", async () => {
|
it("should call service.getQuestion", async () => {
|
||||||
const data = { id: "q-1", content: "Q" };
|
const data = { id: "q-1", content: "Q" };
|
||||||
@@ -104,4 +122,48 @@ describe("QuestionsController", () => {
|
|||||||
expect(result).toEqual({ success: true, data: { success: true } });
|
expect(result).toEqual({ success: true, data: { success: true } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// P6.1: 状态机端点测试
|
||||||
|
describe("submitReview", () => {
|
||||||
|
it("should call service.submitForReview", async () => {
|
||||||
|
const result = await controller.submitReview("q-1");
|
||||||
|
expect(mockService.submitForReview).toHaveBeenCalledWith("q-1");
|
||||||
|
expect(result).toEqual({ success: true, data: { success: true } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approve", () => {
|
||||||
|
it("should call service.approveQuestion", async () => {
|
||||||
|
const result = await controller.approve("q-1");
|
||||||
|
expect(mockService.approveQuestion).toHaveBeenCalledWith("q-1");
|
||||||
|
expect(result).toEqual({ success: true, data: { success: true } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reject", () => {
|
||||||
|
it("should parse reason and call service.rejectQuestion", async () => {
|
||||||
|
const result = await controller.reject("q-1", { reason: "bad content" });
|
||||||
|
expect(mockService.rejectQuestion).toHaveBeenCalledWith(
|
||||||
|
"q-1",
|
||||||
|
"bad content",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ success: true, data: { success: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on missing reason", async () => {
|
||||||
|
await expect(controller.reject("q-1", {})).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on empty reason", async () => {
|
||||||
|
await expect(controller.reject("q-1", { reason: "" })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archive", () => {
|
||||||
|
it("should call service.archiveQuestion", async () => {
|
||||||
|
const result = await controller.archive("q-1");
|
||||||
|
expect(mockService.archiveQuestion).toHaveBeenCalledWith("q-1");
|
||||||
|
expect(result).toEqual({ success: true, data: { success: true } });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
createQuestionSchema,
|
createQuestionSchema,
|
||||||
updateQuestionSchema,
|
updateQuestionSchema,
|
||||||
listQuestionsSchema,
|
listQuestionsSchema,
|
||||||
|
searchQuestionsSchema,
|
||||||
|
rejectQuestionSchema,
|
||||||
} from "./questions.dto.js";
|
} from "./questions.dto.js";
|
||||||
|
|
||||||
@Controller("questions")
|
@Controller("questions")
|
||||||
@@ -53,6 +55,27 @@ export class QuestionsController {
|
|||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全文检索端点:声明在 @Get(":id") 之前,避免 "search" 被当作 id 参数捕获。
|
||||||
|
// 优先使用 ES;ES 不可用时服务层自动降级到 MySQL LIKE 查询。
|
||||||
|
@Get("search")
|
||||||
|
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||||
|
async search(@Query() query: unknown): Promise<{
|
||||||
|
success: true;
|
||||||
|
data: { items: Question[]; total: number; page: number; pageSize: number };
|
||||||
|
}> {
|
||||||
|
const input = searchQuestionsSchema.parse(query);
|
||||||
|
const { items, total } = await this.service.searchQuestions(input);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: input.page,
|
||||||
|
pageSize: input.pageSize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||||
async getById(
|
async getById(
|
||||||
@@ -81,4 +104,48 @@ export class QuestionsController {
|
|||||||
await this.service.deleteQuestion(id);
|
await this.service.deleteQuestion(id);
|
||||||
return { success: true, data: { success: true } };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== P6.1: Question 审核工作流状态机端点 ==========
|
||||||
|
|
||||||
|
// 提交审核:draft → pending_review
|
||||||
|
@Post(":id/submit-review")
|
||||||
|
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
|
||||||
|
async submitReview(
|
||||||
|
@Param("id") id: string,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
|
await this.service.submitForReview(id);
|
||||||
|
return { success: true, data: { success: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审核通过:pending_review → published
|
||||||
|
@Post(":id/approve")
|
||||||
|
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
|
||||||
|
async approve(
|
||||||
|
@Param("id") id: string,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
|
await this.service.approveQuestion(id);
|
||||||
|
return { success: true, data: { success: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审核拒绝:pending_review → rejected
|
||||||
|
@Post(":id/reject")
|
||||||
|
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
|
||||||
|
async reject(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
|
const { reason } = rejectQuestionSchema.parse(body);
|
||||||
|
await this.service.rejectQuestion(id, reason);
|
||||||
|
return { success: true, data: { success: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归档:published → archived
|
||||||
|
@Post(":id/archive")
|
||||||
|
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
|
||||||
|
async archive(
|
||||||
|
@Param("id") id: string,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
|
await this.service.archiveQuestion(id);
|
||||||
|
return { success: true, data: { success: true } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ export const listQuestionsSchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const searchQuestionsSchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
type: questionTypeSchema.optional(),
|
||||||
|
difficulty: z.coerce.number().int().min(1).max(5).optional(),
|
||||||
|
knowledgePointId: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// P6.1: 审核拒绝原因 schema
|
||||||
|
export const rejectQuestionSchema = z.object({
|
||||||
|
reason: z.string().min(1).max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateQuestionDto = z.infer<typeof createQuestionSchema>;
|
export type CreateQuestionDto = z.infer<typeof createQuestionSchema>;
|
||||||
export type UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;
|
export type UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;
|
||||||
export type ListQuestionsDto = z.infer<typeof listQuestionsSchema>;
|
export type ListQuestionsDto = z.infer<typeof listQuestionsSchema>;
|
||||||
|
export type SearchQuestionsDto = z.infer<typeof searchQuestionsSchema>;
|
||||||
|
export type RejectQuestionDto = z.infer<typeof rejectQuestionSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, like, or, and, count } from "drizzle-orm";
|
||||||
import { getDb } from "../config/database.js";
|
import { getDb } from "../config/database.js";
|
||||||
import {
|
import {
|
||||||
questions,
|
questions,
|
||||||
@@ -61,6 +61,61 @@ export class QuestionsRepository {
|
|||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await getDb().delete(questions).where(eq(questions.id, id));
|
await getDb().delete(questions).where(eq(questions.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL LIKE 模糊检索(ES 降级备选)。
|
||||||
|
* 在 content / answer / explanation 任一字段上匹配关键词,
|
||||||
|
* 同时支持 type / difficulty / knowledgePointId 过滤。
|
||||||
|
* 返回 items + total,total 为满足条件的总行数(不含分页)。
|
||||||
|
*/
|
||||||
|
async search(query: {
|
||||||
|
q?: string;
|
||||||
|
type?: string;
|
||||||
|
difficulty?: number;
|
||||||
|
knowledgePointId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ items: Question[]; total: number }> {
|
||||||
|
const db = getDb();
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (query.q) {
|
||||||
|
const pattern = `%${query.q}%`;
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(questions.content, pattern),
|
||||||
|
like(questions.answer, pattern),
|
||||||
|
like(questions.explanation, pattern),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.type) {
|
||||||
|
conditions.push(eq(questions.type, query.type));
|
||||||
|
}
|
||||||
|
if (query.difficulty !== undefined) {
|
||||||
|
conditions.push(eq(questions.difficulty, query.difficulty));
|
||||||
|
}
|
||||||
|
if (query.knowledgePointId) {
|
||||||
|
conditions.push(eq(questions.knowledgePointId, query.knowledgePointId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
|
||||||
|
const [items, totalRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(questions)
|
||||||
|
.where(where)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((page - 1) * pageSize),
|
||||||
|
db.select({ value: count() }).from(questions).where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = totalRows[0]?.value ?? 0;
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const questionsRepository = new QuestionsRepository();
|
export const questionsRepository = new QuestionsRepository();
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { QuestionsService } from "./questions.service.js";
|
import { QuestionsService } from "./questions.service.js";
|
||||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
|
const mockGetEsClient = vi.hoisted(() => vi.fn());
|
||||||
|
const mockEsClient = vi.hoisted(() => ({
|
||||||
|
search: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/elasticsearch.js", () => ({
|
||||||
|
getEsClient: mockGetEsClient,
|
||||||
|
QUESTION_INDEX_NAME: "content_questions",
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./questions.repository.js", () => ({
|
vi.mock("./questions.repository.js", () => ({
|
||||||
questionsRepository: {
|
questionsRepository: {
|
||||||
@@ -10,6 +23,14 @@ vi.mock("./questions.repository.js", () => ({
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
|
search: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../shared/observability/metrics.js", () => ({
|
||||||
|
questionSearchTotal: { labels: vi.fn().mockReturnValue({ inc: vi.fn() }) },
|
||||||
|
questionSearchLatencyMs: {
|
||||||
|
labels: vi.fn().mockReturnValue({ observe: vi.fn() }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -24,6 +45,8 @@ describe("QuestionsService", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// 默认 ES 未配置:searchQuestions 走 MySQL 降级路径
|
||||||
|
mockGetEsClient.mockReturnValue(null);
|
||||||
service = new QuestionsService(mockOutbox as never);
|
service = new QuestionsService(mockOutbox as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,4 +197,297 @@ describe("QuestionsService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== P6.1: 状态机测试 ==========
|
||||||
|
describe("P6.1 state machine", () => {
|
||||||
|
const draftQuestion = {
|
||||||
|
id: "q-1",
|
||||||
|
knowledgePointId: "kp-1",
|
||||||
|
type: "single_choice",
|
||||||
|
content: "Q",
|
||||||
|
options: null,
|
||||||
|
answer: "A",
|
||||||
|
explanation: null,
|
||||||
|
difficulty: 3,
|
||||||
|
status: "draft",
|
||||||
|
source: "manual",
|
||||||
|
createdBy: "u",
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("submitForReview", () => {
|
||||||
|
it("should transition draft → pending_review", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
await service.submitForReview("q-1");
|
||||||
|
expect(questionsRepository.update).toHaveBeenCalledWith("q-1", {
|
||||||
|
status: "pending_review",
|
||||||
|
});
|
||||||
|
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||||
|
"question.updated",
|
||||||
|
"Question",
|
||||||
|
"q-1",
|
||||||
|
{ status: "pending_review" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ValidationError on illegal transition", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
await expect(service.submitForReview("q-1")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approveQuestion", () => {
|
||||||
|
it("should transition pending_review → published", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "pending_review",
|
||||||
|
});
|
||||||
|
await service.approveQuestion("q-1");
|
||||||
|
expect(questionsRepository.update).toHaveBeenCalledWith("q-1", {
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||||
|
"question.published",
|
||||||
|
"Question",
|
||||||
|
"q-1",
|
||||||
|
{ status: "published" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ValidationError when not pending_review", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
await expect(service.approveQuestion("q-1")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rejectQuestion", () => {
|
||||||
|
it("should transition pending_review → rejected with reason", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "pending_review",
|
||||||
|
});
|
||||||
|
await service.rejectQuestion("q-1", "content issue");
|
||||||
|
expect(questionsRepository.update).toHaveBeenCalledWith("q-1", {
|
||||||
|
status: "rejected",
|
||||||
|
});
|
||||||
|
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||||
|
"question.updated",
|
||||||
|
"Question",
|
||||||
|
"q-1",
|
||||||
|
{ status: "rejected", reject_reason: "content issue" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ValidationError when not pending_review", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
await expect(service.rejectQuestion("q-1", "reason")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archiveQuestion", () => {
|
||||||
|
it("should transition published → archived", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
await service.archiveQuestion("q-1");
|
||||||
|
expect(questionsRepository.update).toHaveBeenCalledWith("q-1", {
|
||||||
|
status: "archived",
|
||||||
|
});
|
||||||
|
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||||
|
"question.updated",
|
||||||
|
"Question",
|
||||||
|
"q-1",
|
||||||
|
{ status: "archived" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ValidationError when not published", async () => {
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||||
|
...draftQuestion,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
await expect(service.archiveQuestion("q-1")).rejects.toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchQuestions", () => {
|
||||||
|
const sampleQuestion = {
|
||||||
|
id: "q-1",
|
||||||
|
knowledgePointId: "kp-1",
|
||||||
|
type: "single_choice",
|
||||||
|
content: "What is 2+2?",
|
||||||
|
options: null,
|
||||||
|
answer: "4",
|
||||||
|
explanation: null,
|
||||||
|
difficulty: 3,
|
||||||
|
status: "draft",
|
||||||
|
source: "manual",
|
||||||
|
createdBy: "u",
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("ES 未配置时降级到 MySQL LIKE 查询", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(null);
|
||||||
|
vi.mocked(questionsRepository.search).mockResolvedValue({
|
||||||
|
items: [sampleQuestion],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.searchQuestions({ q: "2+2" });
|
||||||
|
|
||||||
|
expect(questionsRepository.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ q: "2+2", page: 1, pageSize: 20 }),
|
||||||
|
);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 配置且查询成功时使用 ES 结果", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockResolvedValue({
|
||||||
|
hits: {
|
||||||
|
total: { relation: "eq", value: 1 },
|
||||||
|
hits: [{ _source: { question_id: "q-1" } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(questionsRepository.findById).mockResolvedValue(sampleQuestion);
|
||||||
|
|
||||||
|
const result = await service.searchQuestions({ q: "math" });
|
||||||
|
|
||||||
|
expect(mockEsClient.search).toHaveBeenCalled();
|
||||||
|
expect(questionsRepository.search).not.toHaveBeenCalled();
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]?.id).toBe("q-1");
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 查询抛错时降级到 MySQL", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockRejectedValue(new Error("ES down"));
|
||||||
|
vi.mocked(questionsRepository.search).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.searchQuestions({ q: "x" });
|
||||||
|
|
||||||
|
expect(questionsRepository.search).toHaveBeenCalled();
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 返回 total 为数字时正确解析", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockResolvedValue({
|
||||||
|
hits: {
|
||||||
|
total: 5,
|
||||||
|
hits: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.searchQuestions({ q: "x" });
|
||||||
|
|
||||||
|
expect(result.total).toBe(5);
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 无命中时返回空数组", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockResolvedValue({
|
||||||
|
hits: {
|
||||||
|
total: { relation: "eq", value: 0 },
|
||||||
|
hits: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.searchQuestions({ q: "nomatch" });
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(questionsRepository.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("带过滤条件的 ES 查询应构造 filter 子句", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockResolvedValue({
|
||||||
|
hits: { total: 0, hits: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.searchQuestions({
|
||||||
|
q: "keyword",
|
||||||
|
type: "single_choice",
|
||||||
|
difficulty: 3,
|
||||||
|
knowledgePointId: "kp-1",
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchCall = mockEsClient.search.mock.calls[0]?.[0];
|
||||||
|
expect(searchCall?.from).toBe(10);
|
||||||
|
expect(searchCall?.size).toBe(10);
|
||||||
|
const boolQuery = searchCall?.query?.bool;
|
||||||
|
expect(boolQuery?.must).toBeDefined();
|
||||||
|
expect(boolQuery?.filter).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无 q 参数且无过滤条件时使用 match_all", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockResolvedValue({
|
||||||
|
hits: { total: 0, hits: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.searchQuestions({});
|
||||||
|
|
||||||
|
const searchCall = mockEsClient.search.mock.calls[0]?.[0];
|
||||||
|
expect(searchCall?.query).toEqual({ match_all: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 命中的 question_id 在 DB 中不存在时跳过该条", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.search.mockResolvedValue({
|
||||||
|
hits: {
|
||||||
|
total: { relation: "eq", value: 2 },
|
||||||
|
hits: [
|
||||||
|
{ _source: { question_id: "q-1" } },
|
||||||
|
{ _source: { question_id: "q-missing" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(questionsRepository.findById)
|
||||||
|
.mockResolvedValueOnce(sampleQuestion)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const result = await service.searchQuestions({ q: "x" });
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]?.id).toBe("q-1");
|
||||||
|
// total 仍来自 ES 的命中总数
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { questionsRepository } from "./questions.repository.js";
|
import { questionsRepository } from "./questions.repository.js";
|
||||||
import type { Question, NewQuestion } from "./questions.schema.js";
|
import type { Question, NewQuestion } from "./questions.schema.js";
|
||||||
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
||||||
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
||||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
import { getEsClient, QUESTION_INDEX_NAME } from "../config/elasticsearch.js";
|
||||||
|
import {
|
||||||
|
questionSearchTotal,
|
||||||
|
questionSearchLatencyMs,
|
||||||
|
} from "../shared/observability/metrics.js";
|
||||||
|
|
||||||
|
// Question 状态机允许的状态值
|
||||||
|
export type QuestionStatus =
|
||||||
|
"draft" | "pending_review" | "published" | "rejected" | "archived";
|
||||||
|
|
||||||
|
// 合法状态转换映射:from → Set<to>
|
||||||
|
// 严格按 P6.1 状态机定义:draft → pending_review → published/rejected → archived
|
||||||
|
const ALLOWED_TRANSITIONS: Record<QuestionStatus, Set<QuestionStatus>> = {
|
||||||
|
draft: new Set<QuestionStatus>(["pending_review"]),
|
||||||
|
pending_review: new Set<QuestionStatus>(["published", "rejected"]),
|
||||||
|
published: new Set<QuestionStatus>(["archived"]),
|
||||||
|
rejected: new Set<QuestionStatus>(),
|
||||||
|
archived: new Set<QuestionStatus>(),
|
||||||
|
};
|
||||||
|
|
||||||
export interface CreateQuestionInput {
|
export interface CreateQuestionInput {
|
||||||
knowledgePointId: string;
|
knowledgePointId: string;
|
||||||
@@ -37,8 +59,24 @@ export interface ListQuestionsInput {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchQuestionsInput {
|
||||||
|
q?: string;
|
||||||
|
type?: string;
|
||||||
|
difficulty?: number;
|
||||||
|
knowledgePointId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchQuestionsResult {
|
||||||
|
items: Question[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QuestionsService {
|
export class QuestionsService {
|
||||||
|
private readonly logger = new Logger(QuestionsService.name);
|
||||||
|
|
||||||
constructor(private readonly outbox: OutboxService) {}
|
constructor(private readonly outbox: OutboxService) {}
|
||||||
|
|
||||||
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
|
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
|
||||||
@@ -119,4 +157,205 @@ export class QuestionsService {
|
|||||||
{ deleted: true },
|
{ deleted: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== P6.1: Question 审核工作流状态机 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交审核:draft → pending_review
|
||||||
|
*/
|
||||||
|
async submitForReview(id: string): Promise<void> {
|
||||||
|
const question = await this.getQuestion(id);
|
||||||
|
this.assertTransition(question.status as QuestionStatus, "pending_review");
|
||||||
|
await questionsRepository.update(id, { status: "pending_review" });
|
||||||
|
|
||||||
|
await this.outbox.publish(
|
||||||
|
EVENT_TYPES.QUESTION_UPDATED,
|
||||||
|
AGGREGATE_TYPES.QUESTION,
|
||||||
|
id,
|
||||||
|
{ status: "pending_review" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过:pending_review → published
|
||||||
|
*/
|
||||||
|
async approveQuestion(id: string): Promise<void> {
|
||||||
|
const question = await this.getQuestion(id);
|
||||||
|
this.assertTransition(question.status as QuestionStatus, "published");
|
||||||
|
await questionsRepository.update(id, { status: "published" });
|
||||||
|
|
||||||
|
await this.outbox.publish(
|
||||||
|
EVENT_TYPES.QUESTION_PUBLISHED,
|
||||||
|
AGGREGATE_TYPES.QUESTION,
|
||||||
|
id,
|
||||||
|
{ status: "published" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核拒绝:pending_review → rejected
|
||||||
|
*/
|
||||||
|
async rejectQuestion(id: string, reason: string): Promise<void> {
|
||||||
|
const question = await this.getQuestion(id);
|
||||||
|
this.assertTransition(question.status as QuestionStatus, "rejected");
|
||||||
|
await questionsRepository.update(id, { status: "rejected" });
|
||||||
|
|
||||||
|
await this.outbox.publish(
|
||||||
|
EVENT_TYPES.QUESTION_UPDATED,
|
||||||
|
AGGREGATE_TYPES.QUESTION,
|
||||||
|
id,
|
||||||
|
{ status: "rejected", reject_reason: reason },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档:published → archived
|
||||||
|
*/
|
||||||
|
async archiveQuestion(id: string): Promise<void> {
|
||||||
|
const question = await this.getQuestion(id);
|
||||||
|
this.assertTransition(question.status as QuestionStatus, "archived");
|
||||||
|
await questionsRepository.update(id, { status: "archived" });
|
||||||
|
|
||||||
|
await this.outbox.publish(
|
||||||
|
EVENT_TYPES.QUESTION_UPDATED,
|
||||||
|
AGGREGATE_TYPES.QUESTION,
|
||||||
|
id,
|
||||||
|
{ status: "archived" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验状态转换是否合法,非法则抛出 ValidationError。
|
||||||
|
*/
|
||||||
|
private assertTransition(from: QuestionStatus, to: QuestionStatus): void {
|
||||||
|
const allowed = ALLOWED_TRANSITIONS[from];
|
||||||
|
if (!allowed || !allowed.has(to)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Illegal question status transition: ${from} → ${to}`,
|
||||||
|
{ from, to },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== P5: 全文检索(ES 优先,MySQL 降级) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全文检索题目:优先使用 ES,ES 不可用时降级到 MySQL LIKE 查询。
|
||||||
|
*
|
||||||
|
* ES 检索策略:
|
||||||
|
* - 关键词 q 走 multi_match(content / answer / explanation)
|
||||||
|
* - type / difficulty / knowledgePointId 走 filter(精确匹配,利用缓存)
|
||||||
|
* - 分页使用 from / size
|
||||||
|
*
|
||||||
|
* 降级策略:
|
||||||
|
* - ES Client 未初始化(env.ES_URL 未配置)→ 直接走 MySQL
|
||||||
|
* - ES 查询抛错(集群不可达等)→ log warn 并回退 MySQL
|
||||||
|
*
|
||||||
|
* P6.4: 同时记录 Prometheus 指标(search total + latency)。
|
||||||
|
*/
|
||||||
|
async searchQuestions(
|
||||||
|
input: SearchQuestionsInput,
|
||||||
|
): Promise<SearchQuestionsResult> {
|
||||||
|
const page = input.page ?? 1;
|
||||||
|
const pageSize = input.pageSize ?? 20;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const client = getEsClient();
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
const result = await this.searchViaEs(client, input, page, pageSize);
|
||||||
|
this.recordSearchMetrics("es", startTime);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`ES search failed, falling back to MySQL: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await questionsRepository.search({
|
||||||
|
q: input.q,
|
||||||
|
type: input.type,
|
||||||
|
difficulty: input.difficulty,
|
||||||
|
knowledgePointId: input.knowledgePointId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
this.recordSearchMetrics("mysql", startTime);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSearchMetrics(source: string, startTime: number): void {
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
questionSearchTotal.labels(source).inc();
|
||||||
|
questionSearchLatencyMs.labels(source).observe(latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchViaEs(
|
||||||
|
client: NonNullable<ReturnType<typeof getEsClient>>,
|
||||||
|
input: SearchQuestionsInput,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<SearchQuestionsResult> {
|
||||||
|
const must: unknown[] = [];
|
||||||
|
const filter: unknown[] = [];
|
||||||
|
|
||||||
|
if (input.q) {
|
||||||
|
must.push({
|
||||||
|
multi_match: {
|
||||||
|
query: input.q,
|
||||||
|
fields: ["content", "answer", "explanation"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.type) {
|
||||||
|
filter.push({ term: { type: input.type } });
|
||||||
|
}
|
||||||
|
if (input.difficulty !== undefined) {
|
||||||
|
filter.push({ term: { difficulty: input.difficulty } });
|
||||||
|
}
|
||||||
|
if (input.knowledgePointId) {
|
||||||
|
filter.push({ term: { knowledge_point_id: input.knowledgePointId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const boolClause: Record<string, unknown> = {};
|
||||||
|
if (must.length > 0) boolClause.must = must;
|
||||||
|
if (filter.length > 0) boolClause.filter = filter;
|
||||||
|
|
||||||
|
const response = await client.search({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
from: (page - 1) * pageSize,
|
||||||
|
size: pageSize,
|
||||||
|
query:
|
||||||
|
Object.keys(boolClause).length > 0
|
||||||
|
? { bool: boolClause }
|
||||||
|
: { match_all: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = response.hits?.hits ?? [];
|
||||||
|
const total =
|
||||||
|
typeof response.hits?.total === "number"
|
||||||
|
? response.hits.total
|
||||||
|
: (response.hits?.total?.value ?? 0);
|
||||||
|
|
||||||
|
// ES search 默认 TDocument=unknown,此处从 unknown 转换获取 question_id
|
||||||
|
const ids = hits
|
||||||
|
.map((h) => {
|
||||||
|
const source = h._source as Record<string, unknown> | undefined;
|
||||||
|
return source?.question_id;
|
||||||
|
})
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return { items: [], total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 MySQL 读取完整记录(ES 只存储检索字段,回查 DB 获取 options/metadata 等全字段)
|
||||||
|
const items: Question[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const q = await questionsRepository.findById(id);
|
||||||
|
if (q) items.push(q);
|
||||||
|
}
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { HttpException } from "@nestjs/common";
|
|||||||
const mockGetDb = vi.fn();
|
const mockGetDb = vi.fn();
|
||||||
const mockGetNeo4jSession = vi.fn();
|
const mockGetNeo4jSession = vi.fn();
|
||||||
const mockIsKafkaConnected = vi.fn();
|
const mockIsKafkaConnected = vi.fn();
|
||||||
|
const mockGetEsClient = vi.fn();
|
||||||
|
const mockEsClient = vi.hoisted(() => ({
|
||||||
|
indices: { exists: vi.fn() },
|
||||||
|
}));
|
||||||
|
const mockCountPending = vi.fn();
|
||||||
|
const mockOutboxPendingGauge = vi.hoisted(() => ({ set: vi.fn() }));
|
||||||
|
|
||||||
vi.mock("../../config/database.js", () => ({
|
vi.mock("../../config/database.js", () => ({
|
||||||
getDb: () => mockGetDb(),
|
getDb: () => mockGetDb(),
|
||||||
@@ -17,10 +23,23 @@ vi.mock("../../config/kafka.js", () => ({
|
|||||||
isKafkaProducerConnected: () => mockIsKafkaConnected(),
|
isKafkaProducerConnected: () => mockIsKafkaConnected(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/elasticsearch.js", () => ({
|
||||||
|
getEsClient: () => mockGetEsClient(),
|
||||||
|
QUESTION_INDEX_NAME: "content_questions",
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/env.js", () => ({
|
vi.mock("../../config/env.js", () => ({
|
||||||
env: { NEO4J_URL: undefined, NEO4J_PASSWORD: undefined },
|
env: { NEO4J_URL: undefined, NEO4J_PASSWORD: undefined },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../outbox/outbox.repository.js", () => ({
|
||||||
|
outboxRepository: { countPending: () => mockCountPending() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../observability/metrics.js", () => ({
|
||||||
|
contentOutboxPendingTotal: mockOutboxPendingGauge,
|
||||||
|
}));
|
||||||
|
|
||||||
import { HealthController } from "./health.controller.js";
|
import { HealthController } from "./health.controller.js";
|
||||||
|
|
||||||
describe("HealthController", () => {
|
describe("HealthController", () => {
|
||||||
@@ -28,6 +47,9 @@ describe("HealthController", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// 默认:ES 未配置,outbox pending = 0
|
||||||
|
mockGetEsClient.mockReturnValue(null);
|
||||||
|
mockCountPending.mockResolvedValue(0);
|
||||||
controller = new HealthController();
|
controller = new HealthController();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,9 +70,16 @@ describe("HealthController", () => {
|
|||||||
mockIsKafkaConnected.mockReturnValue(true);
|
mockIsKafkaConnected.mockReturnValue(true);
|
||||||
const result = await controller.readiness();
|
const result = await controller.readiness();
|
||||||
expect(result.status).toBe("ok");
|
expect(result.status).toBe("ok");
|
||||||
expect(result.dependencies).toHaveLength(3);
|
// mysql, neo4j, kafka, outbox, elasticsearch
|
||||||
|
expect(result.dependencies).toHaveLength(5);
|
||||||
const names = result.dependencies.map((d) => d.name);
|
const names = result.dependencies.map((d) => d.name);
|
||||||
expect(names).toEqual(["mysql", "neo4j", "kafka"]);
|
expect(names).toEqual([
|
||||||
|
"mysql",
|
||||||
|
"neo4j",
|
||||||
|
"kafka",
|
||||||
|
"outbox",
|
||||||
|
"elasticsearch",
|
||||||
|
]);
|
||||||
expect(result.dependencies.every((d) => d.status === "ok")).toBe(true);
|
expect(result.dependencies.every((d) => d.status === "ok")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +93,17 @@ describe("HealthController", () => {
|
|||||||
expect(neo4jDep?.status).toBe("ok");
|
expect(neo4jDep?.status).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should report elasticsearch as ok when not configured", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
execute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockIsKafkaConnected.mockReturnValue(true);
|
||||||
|
mockGetEsClient.mockReturnValue(null);
|
||||||
|
const result = await controller.readiness();
|
||||||
|
const esDep = result.dependencies.find((d) => d.name === "elasticsearch");
|
||||||
|
expect(esDep?.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
it("should report mysql error when db check fails", async () => {
|
it("should report mysql error when db check fails", async () => {
|
||||||
mockGetDb.mockReturnValue({
|
mockGetDb.mockReturnValue({
|
||||||
execute: vi.fn().mockRejectedValue(new Error("db down")),
|
execute: vi.fn().mockRejectedValue(new Error("db down")),
|
||||||
@@ -79,5 +119,56 @@ describe("HealthController", () => {
|
|||||||
mockIsKafkaConnected.mockReturnValue(false);
|
mockIsKafkaConnected.mockReturnValue(false);
|
||||||
await expect(controller.readiness()).rejects.toThrow(HttpException);
|
await expect(controller.readiness()).rejects.toThrow(HttpException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should mark outbox as degraded when pending > 100", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
execute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockIsKafkaConnected.mockReturnValue(true);
|
||||||
|
mockCountPending.mockResolvedValue(150);
|
||||||
|
const result = await controller.readiness();
|
||||||
|
expect(result.status).toBe("degraded");
|
||||||
|
const outboxDep = result.dependencies.find((d) => d.name === "outbox");
|
||||||
|
expect(outboxDep?.status).toBe("degraded");
|
||||||
|
// degraded 仍返回 200(不抛异常)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark outbox as ok when pending <= 100", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
execute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockIsKafkaConnected.mockReturnValue(true);
|
||||||
|
mockCountPending.mockResolvedValue(50);
|
||||||
|
const result = await controller.readiness();
|
||||||
|
const outboxDep = result.dependencies.find((d) => d.name === "outbox");
|
||||||
|
expect(outboxDep?.status).toBe("ok");
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark elasticsearch as degraded when index not found", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
execute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockIsKafkaConnected.mockReturnValue(true);
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.indices.exists.mockResolvedValue(false);
|
||||||
|
const result = await controller.readiness();
|
||||||
|
const esDep = result.dependencies.find((d) => d.name === "elasticsearch");
|
||||||
|
expect(esDep?.status).toBe("degraded");
|
||||||
|
expect(result.status).toBe("degraded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should report elasticsearch as ok when index exists", async () => {
|
||||||
|
mockGetDb.mockReturnValue({
|
||||||
|
execute: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockIsKafkaConnected.mockReturnValue(true);
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsClient.indices.exists.mockResolvedValue(true);
|
||||||
|
const result = await controller.readiness();
|
||||||
|
const esDep = result.dependencies.find((d) => d.name === "elasticsearch");
|
||||||
|
expect(esDep?.status).toBe("ok");
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,18 +3,28 @@ import { sql } from "drizzle-orm";
|
|||||||
import { getDb } from "../../config/database.js";
|
import { getDb } from "../../config/database.js";
|
||||||
import { getNeo4jSession } from "../../config/neo4j.js";
|
import { getNeo4jSession } from "../../config/neo4j.js";
|
||||||
import { isKafkaProducerConnected } from "../../config/kafka.js";
|
import { isKafkaProducerConnected } from "../../config/kafka.js";
|
||||||
|
import {
|
||||||
|
getEsClient,
|
||||||
|
QUESTION_INDEX_NAME,
|
||||||
|
} from "../../config/elasticsearch.js";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
|
import { outboxRepository } from "../outbox/outbox.repository.js";
|
||||||
|
import { contentOutboxPendingTotal } from "../observability/metrics.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "content";
|
const SERVICE_NAME = "content";
|
||||||
|
|
||||||
|
// P6.4: Outbox pending 数量阈值,超过则标记 degraded
|
||||||
|
const OUTBOX_PENDING_DEGRADED_THRESHOLD = 100;
|
||||||
|
|
||||||
interface DependencyCheck {
|
interface DependencyCheck {
|
||||||
name: string;
|
name: string;
|
||||||
status: "ok" | "error";
|
status: "ok" | "error" | "degraded";
|
||||||
error?: string;
|
error?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReadinessResponse {
|
interface ReadinessResponse {
|
||||||
status: "ok" | "error";
|
status: "ok" | "error" | "degraded";
|
||||||
service: string;
|
service: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
dependencies: DependencyCheck[];
|
dependencies: DependencyCheck[];
|
||||||
@@ -89,10 +99,77 @@ export class HealthController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allOk = dependencies.every((d) => d.status === "ok");
|
// 4. P6.4: Outbox pending count check(> 100 标记 degraded)
|
||||||
const status = allOk ? "ok" : "error";
|
// 同时更新 Prometheus Gauge 指标 content_outbox_pending_total
|
||||||
|
try {
|
||||||
|
const pendingCount = await outboxRepository.countPending();
|
||||||
|
contentOutboxPendingTotal.set(pendingCount);
|
||||||
|
if (pendingCount > OUTBOX_PENDING_DEGRADED_THRESHOLD) {
|
||||||
|
dependencies.push({
|
||||||
|
name: "outbox",
|
||||||
|
status: "degraded",
|
||||||
|
details: {
|
||||||
|
pendingCount,
|
||||||
|
threshold: OUTBOX_PENDING_DEGRADED_THRESHOLD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dependencies.push({
|
||||||
|
name: "outbox",
|
||||||
|
status: "ok",
|
||||||
|
details: { pendingCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dependencies.push({
|
||||||
|
name: "outbox",
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!allOk) {
|
// 5. P6.4: ES index 存在性检查(ES 未配置时标记 ok-skip)
|
||||||
|
const esClient = getEsClient();
|
||||||
|
if (esClient) {
|
||||||
|
try {
|
||||||
|
const exists = await esClient.indices.exists({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
dependencies.push({ name: "elasticsearch", status: "ok" });
|
||||||
|
} else {
|
||||||
|
dependencies.push({
|
||||||
|
name: "elasticsearch",
|
||||||
|
status: "degraded",
|
||||||
|
error: `index ${QUESTION_INDEX_NAME} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dependencies.push({
|
||||||
|
name: "elasticsearch",
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dependencies.push({
|
||||||
|
name: "elasticsearch",
|
||||||
|
status: "ok",
|
||||||
|
error: "not configured (optional)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 综合状态:任一 error → error;任一 degraded(无 error)→ degraded;否则 ok
|
||||||
|
const hasError = dependencies.some((d) => d.status === "error");
|
||||||
|
const hasDegraded = dependencies.some((d) => d.status === "degraded");
|
||||||
|
const status: ReadinessResponse["status"] = hasError
|
||||||
|
? "error"
|
||||||
|
: hasDegraded
|
||||||
|
? "degraded"
|
||||||
|
: "ok";
|
||||||
|
|
||||||
|
// error 时返回 503;degraded 时仍返回 200(服务可用,但需关注)
|
||||||
|
if (hasError) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -21,8 +21,57 @@ registry.registerMetric(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// P6.4: 可观测性硬化指标
|
||||||
|
// 待发布的 Outbox 事件数(由 health check 或定时任务设置)
|
||||||
|
const contentOutboxPendingTotal = new promClient.Gauge({
|
||||||
|
name: "content_outbox_pending_total",
|
||||||
|
help: "Number of pending outbox events awaiting publication",
|
||||||
|
});
|
||||||
|
registry.registerMetric(contentOutboxPendingTotal);
|
||||||
|
|
||||||
|
// Neo4j 同步延迟(毫秒):最近一次事件时间距现在的差值
|
||||||
|
const contentNeo4jSyncLagMs = new promClient.Gauge({
|
||||||
|
name: "content_neo4j_sync_lag_ms",
|
||||||
|
help: "Neo4j sync lag in milliseconds",
|
||||||
|
});
|
||||||
|
registry.registerMetric(contentNeo4jSyncLagMs);
|
||||||
|
|
||||||
|
// ES 同步延迟(毫秒)
|
||||||
|
const contentEsSyncLagMs = new promClient.Gauge({
|
||||||
|
name: "content_es_sync_lag_ms",
|
||||||
|
help: "Elasticsearch sync lag in milliseconds",
|
||||||
|
});
|
||||||
|
registry.registerMetric(contentEsSyncLagMs);
|
||||||
|
|
||||||
|
// 题目搜索请求总数(按数据源 es/mysql 标签区分)
|
||||||
|
const contentQuestionSearchTotal = new promClient.Counter({
|
||||||
|
name: "content_question_search_total",
|
||||||
|
help: "Total number of question search requests",
|
||||||
|
labelNames: ["source"],
|
||||||
|
});
|
||||||
|
registry.registerMetric(contentQuestionSearchTotal);
|
||||||
|
|
||||||
|
// 题目搜索延迟(毫秒)
|
||||||
|
const contentQuestionSearchLatencyMs = new promClient.Histogram({
|
||||||
|
name: "content_question_search_latency_ms",
|
||||||
|
help: "Question search latency in milliseconds",
|
||||||
|
labelNames: ["source"],
|
||||||
|
buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
|
||||||
|
});
|
||||||
|
registry.registerMetric(contentQuestionSearchLatencyMs);
|
||||||
|
|
||||||
// 自动收集 Node.js 进程级指标(CPU/内存/事件循环/GC等)
|
// 自动收集 Node.js 进程级指标(CPU/内存/事件循环/GC等)
|
||||||
// 这些指标无需业务代码埋点,prom-client 自动采集
|
// 这些指标无需业务代码埋点,prom-client 自动采集
|
||||||
promClient.collectDefaultMetrics({ register: registry });
|
promClient.collectDefaultMetrics({ register: registry });
|
||||||
|
|
||||||
export { registry as metricsRegistry };
|
export {
|
||||||
|
registry as metricsRegistry,
|
||||||
|
contentOutboxPendingTotal,
|
||||||
|
contentNeo4jSyncLagMs,
|
||||||
|
contentEsSyncLagMs,
|
||||||
|
contentQuestionSearchTotal,
|
||||||
|
contentQuestionSearchLatencyMs,
|
||||||
|
// 兼容性别名:业务代码使用的简短名称
|
||||||
|
contentQuestionSearchTotal as questionSearchTotal,
|
||||||
|
contentQuestionSearchLatencyMs as questionSearchLatencyMs,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, sql, and, or, isNull, lte } from "drizzle-orm";
|
import { eq, sql, and, or, isNull, lte, count } from "drizzle-orm";
|
||||||
import type { MySql2Database } from "drizzle-orm/mysql2";
|
import type { MySql2Database } from "drizzle-orm/mysql2";
|
||||||
import { getDb } from "../../config/database.js";
|
import { getDb } from "../../config/database.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,15 @@ export class OutboxRepository {
|
|||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.4: 统计 pending 状态的 outbox 事件总数(health check + metrics 用)
|
||||||
|
async countPending(): Promise<number> {
|
||||||
|
const [row] = await getDb()
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(outbox)
|
||||||
|
.where(eq(outbox.status, "pending"));
|
||||||
|
return row?.value ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
async markPublished(id: string): Promise<void> {
|
async markPublished(id: string): Promise<void> {
|
||||||
await getDb()
|
await getDb()
|
||||||
.update(outbox)
|
.update(outbox)
|
||||||
|
|||||||
325
services/content/src/shared/sync/es-sync.worker.test.ts
Normal file
325
services/content/src/shared/sync/es-sync.worker.test.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// 使用 vi.hoisted 确保 mock 在 worker 模块导入前注册
|
||||||
|
const mockEsSyncConsumer = vi.hoisted(() => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
run: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEsClient = vi.hoisted(() => ({
|
||||||
|
index: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetEsClient = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
const mockQuestionsRepo = vi.hoisted(() => ({
|
||||||
|
findById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/kafka.js", () => ({
|
||||||
|
esSyncConsumer: mockEsSyncConsumer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/elasticsearch.js", () => ({
|
||||||
|
getEsClient: mockGetEsClient,
|
||||||
|
QUESTION_INDEX_NAME: "content_questions",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../questions/questions.repository.js", () => ({
|
||||||
|
questionsRepository: mockQuestionsRepo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../observability/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { EsSyncWorker } from "./es-sync.worker.js";
|
||||||
|
import { EVENT_TYPES } from "../outbox/events.js";
|
||||||
|
|
||||||
|
// 构造 Kafka 消息(value 为 JSON 字符串)
|
||||||
|
function createMessage(event: Record<string, unknown>): {
|
||||||
|
key: Buffer | null;
|
||||||
|
value: Buffer | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
key: Buffer.from(String(event.aggregate_id ?? "")),
|
||||||
|
value: Buffer.from(JSON.stringify(event)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleQuestion = {
|
||||||
|
id: "q-1",
|
||||||
|
knowledgePointId: "kp-1",
|
||||||
|
type: "single_choice",
|
||||||
|
content: "What is 2+2?",
|
||||||
|
options: null,
|
||||||
|
answer: "4",
|
||||||
|
explanation: "Basic arithmetic",
|
||||||
|
difficulty: 3,
|
||||||
|
status: "draft",
|
||||||
|
source: "manual",
|
||||||
|
createdBy: "u-1",
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("EsSyncWorker", () => {
|
||||||
|
let worker: EsSyncWorker;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
worker = new EsSyncWorker();
|
||||||
|
// 默认 ES 可用
|
||||||
|
mockGetEsClient.mockReturnValue(mockEsClient);
|
||||||
|
mockEsSyncConsumer.run.mockImplementation(({ eachMessage }) => {
|
||||||
|
// 不实际启动消费循环;测试通过调用 handleMessage 验证
|
||||||
|
void eachMessage;
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("start", () => {
|
||||||
|
it("ES 未配置时跳过启动(不连接 Kafka)", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(null);
|
||||||
|
await worker.start();
|
||||||
|
expect(mockEsSyncConsumer.connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 配置时连接 Kafka 并订阅 question topic", async () => {
|
||||||
|
await worker.start();
|
||||||
|
expect(mockEsSyncConsumer.connect).toHaveBeenCalled();
|
||||||
|
expect(mockEsSyncConsumer.subscribe).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ topic: "edu.content.question.events" }),
|
||||||
|
);
|
||||||
|
expect(mockEsSyncConsumer.run).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kafka 连接失败时软失败(不抛出)", async () => {
|
||||||
|
mockEsSyncConsumer.connect.mockRejectedValueOnce(new Error("kafka down"));
|
||||||
|
await expect(worker.start()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stop", () => {
|
||||||
|
it("停止时不抛出错误", async () => {
|
||||||
|
await expect(worker.stop()).resolves.not.toThrow();
|
||||||
|
expect(mockEsSyncConsumer.stop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessage — 事件处理", () => {
|
||||||
|
// 通过类型转换访问私有 handleMessage
|
||||||
|
function handleMessage(
|
||||||
|
w: EsSyncWorker,
|
||||||
|
msg: { key: Buffer | null; value: Buffer | null },
|
||||||
|
): Promise<void> {
|
||||||
|
return (
|
||||||
|
w as unknown as {
|
||||||
|
handleMessage: (m: {
|
||||||
|
key: Buffer | null;
|
||||||
|
value: Buffer | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
).handleMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("question.created → 从 DB 读取并 index 文档", async () => {
|
||||||
|
mockQuestionsRepo.findById.mockResolvedValue(sampleQuestion);
|
||||||
|
const event = {
|
||||||
|
event_id: "e-1",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_CREATED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "created",
|
||||||
|
};
|
||||||
|
await handleMessage(worker, createMessage(event));
|
||||||
|
|
||||||
|
expect(mockQuestionsRepo.findById).toHaveBeenCalledWith("q-1");
|
||||||
|
expect(mockEsClient.index).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
index: "content_questions",
|
||||||
|
id: "q-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const doc = mockEsClient.index.mock.calls[0]?.[0]?.document;
|
||||||
|
expect(doc?.question_id).toBe("q-1");
|
||||||
|
expect(doc?.content).toBe("What is 2+2?");
|
||||||
|
expect(doc?.difficulty).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("question.updated → 重新 index 文档", async () => {
|
||||||
|
mockQuestionsRepo.findById.mockResolvedValue(sampleQuestion);
|
||||||
|
const event = {
|
||||||
|
event_id: "e-2",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_UPDATED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "updated",
|
||||||
|
};
|
||||||
|
await handleMessage(worker, createMessage(event));
|
||||||
|
expect(mockEsClient.index).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("question.published → 重新 index 文档(含新 status)", async () => {
|
||||||
|
mockQuestionsRepo.findById.mockResolvedValue({
|
||||||
|
...sampleQuestion,
|
||||||
|
status: "published",
|
||||||
|
});
|
||||||
|
const event = {
|
||||||
|
event_id: "e-3",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_PUBLISHED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "published",
|
||||||
|
};
|
||||||
|
await handleMessage(worker, createMessage(event));
|
||||||
|
expect(mockEsClient.index).toHaveBeenCalled();
|
||||||
|
const doc = mockEsClient.index.mock.calls[0]?.[0]?.document;
|
||||||
|
expect(doc?.status).toBe("published");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("question.deleted → 删除 ES 文档", async () => {
|
||||||
|
const event = {
|
||||||
|
event_id: "e-4",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_DELETED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "deleted",
|
||||||
|
};
|
||||||
|
await handleMessage(worker, createMessage(event));
|
||||||
|
expect(mockEsClient.delete).toHaveBeenCalledWith({
|
||||||
|
index: "content_questions",
|
||||||
|
id: "q-1",
|
||||||
|
});
|
||||||
|
expect(mockQuestionsRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("question.created 但 DB 中不存在 → 跳过索引", async () => {
|
||||||
|
mockQuestionsRepo.findById.mockResolvedValue(undefined);
|
||||||
|
const event = {
|
||||||
|
event_id: "e-5",
|
||||||
|
aggregate_id: "q-missing",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_CREATED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "created",
|
||||||
|
};
|
||||||
|
await handleMessage(worker, createMessage(event));
|
||||||
|
expect(mockEsClient.index).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未处理的事件类型 → 不调用 ES", async () => {
|
||||||
|
const event = {
|
||||||
|
event_id: "e-6",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: "edu.content.unknown_event",
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "unknown",
|
||||||
|
};
|
||||||
|
await handleMessage(worker, createMessage(event));
|
||||||
|
expect(mockEsClient.index).not.toHaveBeenCalled();
|
||||||
|
expect(mockEsClient.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessage — 幂等去重", () => {
|
||||||
|
function handleMessage(
|
||||||
|
w: EsSyncWorker,
|
||||||
|
msg: { key: Buffer | null; value: Buffer | null },
|
||||||
|
): Promise<void> {
|
||||||
|
return (
|
||||||
|
w as unknown as {
|
||||||
|
handleMessage: (m: {
|
||||||
|
key: Buffer | null;
|
||||||
|
value: Buffer | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
).handleMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("相同 event_id 的事件只处理一次", async () => {
|
||||||
|
mockQuestionsRepo.findById.mockResolvedValue(sampleQuestion);
|
||||||
|
const event = {
|
||||||
|
event_id: "dup-1",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_CREATED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "created",
|
||||||
|
};
|
||||||
|
const msg = createMessage(event);
|
||||||
|
await handleMessage(worker, msg);
|
||||||
|
await handleMessage(worker, msg);
|
||||||
|
expect(mockEsClient.index).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessage — 软失败", () => {
|
||||||
|
function handleMessage(
|
||||||
|
w: EsSyncWorker,
|
||||||
|
msg: { key: Buffer | null; value: Buffer | null },
|
||||||
|
): Promise<void> {
|
||||||
|
return (
|
||||||
|
w as unknown as {
|
||||||
|
handleMessage: (m: {
|
||||||
|
key: Buffer | null;
|
||||||
|
value: Buffer | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
).handleMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("ES Client 为 null 时跳过(不抛出)", async () => {
|
||||||
|
mockGetEsClient.mockReturnValue(null);
|
||||||
|
const event = {
|
||||||
|
event_id: "e-null",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_CREATED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "created",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
handleMessage(worker, createMessage(event)),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
expect(mockEsClient.index).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ES 操作抛错时软失败(不抛出)", async () => {
|
||||||
|
mockQuestionsRepo.findById.mockResolvedValue(sampleQuestion);
|
||||||
|
mockEsClient.index.mockRejectedValueOnce(new Error("ES down"));
|
||||||
|
const event = {
|
||||||
|
event_id: "e-err",
|
||||||
|
aggregate_id: "q-1",
|
||||||
|
event_type: `edu.content.${EVENT_TYPES.QUESTION_CREATED}`,
|
||||||
|
occurred_at: Date.now(),
|
||||||
|
action: "created",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
handleMessage(worker, createMessage(event)),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无效 JSON payload 不抛出", async () => {
|
||||||
|
await expect(
|
||||||
|
handleMessage(worker, {
|
||||||
|
key: Buffer.from("k"),
|
||||||
|
value: Buffer.from("not-json"),
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空 value 不抛出", async () => {
|
||||||
|
await expect(
|
||||||
|
handleMessage(worker, { key: null, value: null }),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
189
services/content/src/shared/sync/es-sync.worker.ts
Normal file
189
services/content/src/shared/sync/es-sync.worker.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { logger } from "../observability/logger.js";
|
||||||
|
import { esSyncConsumer } from "../../config/kafka.js";
|
||||||
|
import {
|
||||||
|
getEsClient,
|
||||||
|
QUESTION_INDEX_NAME,
|
||||||
|
} from "../../config/elasticsearch.js";
|
||||||
|
import { CONTENT_TOPICS, EVENT_TYPES } from "../outbox/events.js";
|
||||||
|
import { questionsRepository } from "../../questions/questions.repository.js";
|
||||||
|
|
||||||
|
interface EventEnvelope {
|
||||||
|
event_id: string;
|
||||||
|
aggregate_id: string;
|
||||||
|
event_type: string;
|
||||||
|
occurred_at: number;
|
||||||
|
action: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等去重 Set:与 neo4j-sync.worker.ts 保持相同模式
|
||||||
|
const processedEvents = new Set<string>();
|
||||||
|
const DEDUP_MAX_SIZE = 10000;
|
||||||
|
|
||||||
|
export class EsSyncWorker {
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const client = getEsClient();
|
||||||
|
if (!client) {
|
||||||
|
logger.info("EsSyncWorker skipped (ES not configured)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await esSyncConsumer.connect();
|
||||||
|
await esSyncConsumer.subscribe({
|
||||||
|
topic: CONTENT_TOPICS.QUESTION,
|
||||||
|
fromBeginning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
await esSyncConsumer.run({
|
||||||
|
eachMessage: async ({ message }) => {
|
||||||
|
await this.handleMessage(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info("EsSyncWorker started");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"EsSyncWorker failed to start (ES/Kafka may be unavailable)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.running = false;
|
||||||
|
try {
|
||||||
|
await esSyncConsumer.stop();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
logger.info("EsSyncWorker stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(message: {
|
||||||
|
key: Buffer | null;
|
||||||
|
value: Buffer | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const value = message.value?.toString();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
let event: EventEnvelope;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(value) as EventEnvelope;
|
||||||
|
} catch {
|
||||||
|
logger.error({ value }, "Failed to parse event payload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等去重
|
||||||
|
if (processedEvents.has(event.event_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processedEvents.add(event.event_id);
|
||||||
|
if (processedEvents.size > DEDUP_MAX_SIZE) {
|
||||||
|
const first = processedEvents.values().next().value;
|
||||||
|
if (first) processedEvents.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getEsClient();
|
||||||
|
if (!client) {
|
||||||
|
logger.debug(
|
||||||
|
{ eventType: event.event_type },
|
||||||
|
"ES not available, skipping sync",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.syncEvent(client, event);
|
||||||
|
} catch (err) {
|
||||||
|
// ES 不可用时软失败:log warn 并跳过,不阻塞消费链
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
eventId: event.event_id,
|
||||||
|
eventType: event.event_type,
|
||||||
|
},
|
||||||
|
"ES sync failed, skipping (soft failure)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncEvent(
|
||||||
|
client: NonNullable<ReturnType<typeof getEsClient>>,
|
||||||
|
event: EventEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (event.event_type) {
|
||||||
|
case `edu.content.${EVENT_TYPES.QUESTION_CREATED}`:
|
||||||
|
case `edu.content.${EVENT_TYPES.QUESTION_UPDATED}`:
|
||||||
|
case `edu.content.${EVENT_TYPES.QUESTION_PUBLISHED}`:
|
||||||
|
await this.indexQuestion(client, event.aggregate_id);
|
||||||
|
break;
|
||||||
|
case `edu.content.${EVENT_TYPES.QUESTION_DELETED}`:
|
||||||
|
await client.delete({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
id: event.aggregate_id,
|
||||||
|
});
|
||||||
|
logger.debug(
|
||||||
|
{ questionId: event.aggregate_id, eventId: event.event_id },
|
||||||
|
"ES document deleted",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.debug(
|
||||||
|
{ eventType: event.event_type },
|
||||||
|
"Unhandled event type for ES sync",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 MySQL 读取题目完整记录并索引到 ES。
|
||||||
|
* 事件只携带部分字段,因此需要回查 DB 以获取 content/answer/explanation。
|
||||||
|
*/
|
||||||
|
private async indexQuestion(
|
||||||
|
client: NonNullable<ReturnType<typeof getEsClient>>,
|
||||||
|
questionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const question = await questionsRepository.findById(questionId);
|
||||||
|
if (!question) {
|
||||||
|
logger.warn(
|
||||||
|
{ questionId },
|
||||||
|
"Question not found in DB, skipping ES index",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.index({
|
||||||
|
index: QUESTION_INDEX_NAME,
|
||||||
|
id: question.id,
|
||||||
|
document: {
|
||||||
|
question_id: question.id,
|
||||||
|
knowledge_point_id: question.knowledgePointId,
|
||||||
|
type: question.type,
|
||||||
|
content: question.content,
|
||||||
|
answer: question.answer,
|
||||||
|
explanation: question.explanation ?? "",
|
||||||
|
difficulty: question.difficulty,
|
||||||
|
status: question.status,
|
||||||
|
source: question.source,
|
||||||
|
created_by: question.createdBy,
|
||||||
|
created_at: question.createdAt.getTime(),
|
||||||
|
updated_at: question.updatedAt.getTime(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.debug(
|
||||||
|
{ questionId: question.id, eventId: "n/a" },
|
||||||
|
"ES document indexed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const esSyncWorker = new EsSyncWorker();
|
||||||
@@ -44,6 +44,20 @@ export class TextbooksController {
|
|||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.3: 版本查询——声明在 @Get(":id") 之前,避免 "versions" 被当作 id 捕获
|
||||||
|
@Get("versions")
|
||||||
|
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
|
||||||
|
async listVersions(
|
||||||
|
@Query("subjectId") subjectId: string,
|
||||||
|
@Query("gradeId") gradeId: string,
|
||||||
|
): Promise<{ success: true; data: Textbook[] }> {
|
||||||
|
if (!subjectId || !gradeId) {
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
const data = await this.service.listVersions(subjectId, gradeId);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
|
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
|
||||||
async getById(
|
async getById(
|
||||||
@@ -72,4 +86,14 @@ export class TextbooksController {
|
|||||||
await this.service.delete(id);
|
await this.service.delete(id);
|
||||||
return { success: true, data: { success: true } };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.3: 归档教材
|
||||||
|
@Post(":id/archive")
|
||||||
|
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
|
||||||
|
async archive(
|
||||||
|
@Param("id") id: string,
|
||||||
|
): Promise<{ success: true; data: { success: true } }> {
|
||||||
|
await this.service.archiveTextbook(id);
|
||||||
|
return { success: true, data: { success: true } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { getDb } from "../config/database.js";
|
import { getDb } from "../config/database.js";
|
||||||
import {
|
import {
|
||||||
textbooks,
|
textbooks,
|
||||||
@@ -35,6 +35,19 @@ export class TextbooksRepository {
|
|||||||
return q.limit(pageSize).offset((page - 1) * pageSize);
|
return q.limit(pageSize).offset((page - 1) * pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P6.3: 查询同 subject + grade 的所有版本(无分页)
|
||||||
|
async findBySubjectAndGrade(
|
||||||
|
subjectId: string,
|
||||||
|
gradeId: string,
|
||||||
|
): Promise<Textbook[]> {
|
||||||
|
return getDb()
|
||||||
|
.select()
|
||||||
|
.from(textbooks)
|
||||||
|
.where(
|
||||||
|
and(eq(textbooks.subjectId, subjectId), eq(textbooks.gradeId, gradeId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: NewTextbook): Promise<void> {
|
async create(data: NewTextbook): Promise<void> {
|
||||||
await getDb().insert(textbooks).values(data);
|
await getDb().insert(textbooks).values(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ vi.mock("./textbooks.repository.js", () => ({
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
find: vi.fn(),
|
find: vi.fn(),
|
||||||
|
findBySubjectAndGrade: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -213,4 +214,65 @@ describe("TextbooksService", () => {
|
|||||||
expect(textbooksRepository.find).toHaveBeenCalledWith({ subjectId: "s" });
|
expect(textbooksRepository.find).toHaveBeenCalledWith({ subjectId: "s" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// P6.3: 教材版本管理测试
|
||||||
|
describe("archiveTextbook", () => {
|
||||||
|
it("should set status to archived and publish archived event", async () => {
|
||||||
|
const existing = {
|
||||||
|
id: "t-1",
|
||||||
|
title: "T",
|
||||||
|
subjectId: "s",
|
||||||
|
gradeId: "g",
|
||||||
|
version: "1.0",
|
||||||
|
status: "published",
|
||||||
|
tenantId: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
vi.mocked(textbooksRepository.findById).mockResolvedValue(existing);
|
||||||
|
|
||||||
|
await service.archiveTextbook("t-1");
|
||||||
|
|
||||||
|
expect(textbooksRepository.update).toHaveBeenCalledWith("t-1", {
|
||||||
|
status: "archived",
|
||||||
|
});
|
||||||
|
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||||
|
"textbook.archived",
|
||||||
|
"Textbook",
|
||||||
|
"t-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "archived",
|
||||||
|
previous_status: "published",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundError when textbook not found", async () => {
|
||||||
|
vi.mocked(textbooksRepository.findById).mockResolvedValue(undefined);
|
||||||
|
await expect(service.archiveTextbook("nonexistent")).rejects.toThrow(
|
||||||
|
NotFoundError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listVersions", () => {
|
||||||
|
it("should call repository findBySubjectAndGrade", async () => {
|
||||||
|
const mockVersions = [
|
||||||
|
{ id: "t-1", version: "1.0" },
|
||||||
|
{ id: "t-2", version: "2.0" },
|
||||||
|
];
|
||||||
|
vi.mocked(textbooksRepository.findBySubjectAndGrade).mockResolvedValue(
|
||||||
|
mockVersions as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.listVersions("subj-1", "grade-3");
|
||||||
|
|
||||||
|
expect(textbooksRepository.findBySubjectAndGrade).toHaveBeenCalledWith(
|
||||||
|
"subj-1",
|
||||||
|
"grade-3",
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,4 +101,36 @@ export class TextbooksService {
|
|||||||
{ deleted: true },
|
{ deleted: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== P6.3: 教材版本管理 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档教材:将 status 置为 archived,发布 TEXTBOOK_ARCHIVED 事件。
|
||||||
|
* 与 update(id, { status: "archived" }) 的区别:
|
||||||
|
* - 显式语义化方法,便于权限粒度控制和审计
|
||||||
|
* - 不接受其他字段变更,仅做状态归档
|
||||||
|
*/
|
||||||
|
async archiveTextbook(id: string): Promise<void> {
|
||||||
|
const existing = await this.getById(id);
|
||||||
|
await textbooksRepository.update(id, { status: "archived" });
|
||||||
|
|
||||||
|
await this.outbox.publish(
|
||||||
|
EVENT_TYPES.TEXTBOOK_ARCHIVED,
|
||||||
|
AGGREGATE_TYPES.TEXTBOOK,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
title: existing.title,
|
||||||
|
status: "archived",
|
||||||
|
previous_status: existing.status,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出同 subject + grade 的所有教材版本(含已归档)。
|
||||||
|
* 用于版本管理界面展示历史版本。
|
||||||
|
*/
|
||||||
|
async listVersions(subjectId: string, gradeId: string): Promise<Textbook[]> {
|
||||||
|
return textbooksRepository.findBySubjectAndGrade(subjectId, gradeId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "test"]
|
"exclude": ["node_modules", "dist", "test", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user