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:
SpecialX
2026-07-14 00:58:50 +08:00
parent 5b06bdbc52
commit 99580fa13a
29 changed files with 2486 additions and 26 deletions

View File

@@ -2,19 +2,45 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY services/content/tsconfig.json services/content/nest-cli.json ./
COPY services/content/src ./src
RUN pnpm build
# 复制 workspace 根配置
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
# 复制 shared-protoproto 文件运行时需要)
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
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
COPY packages/shared-proto/proto/content.proto ./proto/content.proto
# 复制 workspace 配置
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
# 复制 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
WORKDIR /app/services/content
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,286 @@
# content 下一步工作与上下游依赖
> 模块content内容域服务Textbook/Chapter/KnowledgeGraph/Question 四 Service端口 HTTP 3005 / gRPC 50054
> 负责人ai09
> 更新日期2026-07-13P4P6+ 全部完成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 模块已完成 P4P6+ 全部批次,提供 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 WorkerKafka 消费 → 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
测试环境:本地 Dockeredu-mysql + edu-neo4j + edu-es + edu-kafka + edu-redis + edu-content-test均接入 `edu-full_default` 网络)
```
镜像edu/content:test
容器edu-content-testDEV_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]} ✅
测试 2REST 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)
测试 3ES 全文检索
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)
测试 5Outbox 事件驱动
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
测试 6Neo4j 知识图谱同步
Neo4j KnowledgePoint 节点已创建 ✅ (id=tuuv90gloo7kf2x1al0b1b6g, difficulty=2)
GET /knowledge-graph/visualization → nodes=1, edges=0 ✅
测试 7教材归档
POST /textbooks/:id/archive → status=archived ✅
测试 8Prometheus 指标
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-bffai03 负责)— 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-bffai04 负责)— 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-bffai05 负责)— 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-anaai11 负责)— Kafka 消费
| # | 依赖项 | 用途 | 状态 |
| --- | ----------------------------------------------- | ------------------ | ----------------------- |
| 1 | 消费 topic `edu.content.knowledge_point.events` | 分析知识点难度分布 | ✅ content Kafka 已就绪 |
| 2 | 消费 topic `edu.content.question.events` | 题目来源统计 | ✅ content Kafka 已就绪 |
### 3.5 api-gatewayai01 负责)— 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 更新状态。**

View File

@@ -22,6 +22,7 @@
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@elastic/elasticsearch": "^8.13.0",
"@paralleldrive/cuid2": "^2.2.2",
"drizzle-orm": "^0.31.0",
"kafkajs": "^2.2.4",

View 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();
});
});

View 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();
}
}

View File

@@ -16,6 +16,11 @@ export const neo4jSyncConsumer = kafka.consumer({
groupId: "content-neo4j-sync",
});
// ES sync consumer消费题目事件异步索引到 Elasticsearch
export const esSyncConsumer = kafka.consumer({
groupId: "content-es-sync",
});
let producerIsConnected = false;
producer.on("producer.connect", () => {
@@ -54,4 +59,9 @@ export async function disconnectKafka(): Promise<void> {
} catch {
// ignore
}
try {
await esSyncConsumer.disconnect();
} catch {
// ignore
}
}

View File

@@ -6,10 +6,12 @@ import {
Param,
Post,
Put,
Query,
} from "@nestjs/common";
import {
KnowledgePointsService,
type PrerequisiteNode,
type VisualizationResult,
} from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
import {
@@ -104,3 +106,21 @@ export class KnowledgePointsController {
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 };
}
}

View File

@@ -1,11 +1,14 @@
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 { OutboxModule } from "../shared/outbox/outbox.module.js";
@Module({
imports: [OutboxModule],
controllers: [KnowledgePointsController],
controllers: [KnowledgePointsController, KnowledgeGraphController],
providers: [KnowledgePointsService],
exports: [KnowledgePointsService],
})

View File

@@ -23,6 +23,11 @@ export class KnowledgePointsRepository {
.where(eq(knowledgePoints.chapterId, chapterId));
}
// P6.2: 可视化降级查询——返回全部知识点(无分页)
async findAll(): Promise<KnowledgePoint[]> {
return getDb().select().from(knowledgePoints);
}
async create(data: NewKnowledgePoint): Promise<void> {
await getDb().insert(knowledgePoints).values(data);
}

View File

@@ -9,6 +9,7 @@ vi.mock("./knowledge-points.repository.js", () => ({
knowledgePointsRepository: {
findById: vi.fn(),
findByChapterId: vi.fn(),
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
@@ -146,4 +147,57 @@ describe("KnowledgePointsService", () => {
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([]);
});
});
});

View File

@@ -33,6 +33,24 @@ export interface PrerequisiteNode {
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()
export class KnowledgePointsService {
private readonly logger = new Logger(KnowledgePointsService.name);
@@ -188,4 +206,147 @@ export class KnowledgePointsService {
{ 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;
}
}

View File

@@ -14,6 +14,8 @@ import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import { outboxPublisher } from "./shared/outbox/outbox.publisher.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";
/**
@@ -87,15 +89,23 @@ async function bootstrap(): Promise<void> {
// 启动 Outbox Publisher轮询 pending 事件投递 Kafka
await outboxPublisher.start();
// 创建/校验 ES 索引幂等ES 不可用时跳过)
await ensureQuestionIndex();
// 启动 Neo4j Sync Worker消费 Kafka 事件异步同步图谱)
await neo4jSyncWorker.start();
// 启动 ES Sync Worker消费题目事件异步索引到 Elasticsearch
await esSyncWorker.start();
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
await esSyncWorker.stop();
await neo4jSyncWorker.stop();
await outboxPublisher.stop();
await disconnectKafka();
await app.close();
await closeEs();
await closeNeo4j();
await closeDb();
await shutdownTracer();
@@ -104,10 +114,12 @@ async function bootstrap(): Promise<void> {
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await esSyncWorker.stop();
await neo4jSyncWorker.stop();
await outboxPublisher.stop();
await disconnectKafka();
await app.close();
await closeEs();
await closeNeo4j();
await closeDb();
await shutdownTracer();

View File

@@ -12,6 +12,11 @@ const mockService = {
getQuestion: vi.fn(),
updateQuestion: vi.fn(),
deleteQuestion: vi.fn(),
searchQuestions: vi.fn(),
submitForReview: vi.fn(),
approveQuestion: vi.fn(),
rejectQuestion: vi.fn(),
archiveQuestion: vi.fn(),
};
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", () => {
it("should call service.getQuestion", async () => {
const data = { id: "q-1", content: "Q" };
@@ -104,4 +122,48 @@ describe("QuestionsController", () => {
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 } });
});
});
});

View File

@@ -18,6 +18,8 @@ import {
createQuestionSchema,
updateQuestionSchema,
listQuestionsSchema,
searchQuestionsSchema,
rejectQuestionSchema,
} from "./questions.dto.js";
@Controller("questions")
@@ -53,6 +55,27 @@ export class QuestionsController {
return { success: true, data };
}
// 全文检索端点:声明在 @Get(":id") 之前,避免 "search" 被当作 id 参数捕获。
// 优先使用 ESES 不可用时服务层自动降级到 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")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async getById(
@@ -81,4 +104,48 @@ export class QuestionsController {
await this.service.deleteQuestion(id);
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 } };
}
}

View File

@@ -46,6 +46,22 @@ export const listQuestionsSchema = z.object({
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 UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;
export type ListQuestionsDto = z.infer<typeof listQuestionsSchema>;
export type SearchQuestionsDto = z.infer<typeof searchQuestionsSchema>;
export type RejectQuestionDto = z.infer<typeof rejectQuestionSchema>;

View File

@@ -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 {
questions,
@@ -61,6 +61,61 @@ export class QuestionsRepository {
async delete(id: string): Promise<void> {
await getDb().delete(questions).where(eq(questions.id, id));
}
/**
* MySQL LIKE 模糊检索ES 降级备选)。
* 在 content / answer / explanation 任一字段上匹配关键词,
* 同时支持 type / difficulty / knowledgePointId 过滤。
* 返回 items + totaltotal 为满足条件的总行数(不含分页)。
*/
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();

View File

@@ -1,6 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
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", () => ({
questionsRepository: {
@@ -10,6 +23,14 @@ vi.mock("./questions.repository.js", () => ({
create: vi.fn(),
update: 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(() => {
vi.clearAllMocks();
// 默认 ES 未配置searchQuestions 走 MySQL 降级路径
mockGetEsClient.mockReturnValue(null);
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);
});
});
});

View File

@@ -1,10 +1,32 @@
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { questionsRepository } from "./questions.repository.js";
import type { Question, NewQuestion } from "./questions.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.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 {
knowledgePointId: string;
@@ -37,8 +59,24 @@ export interface ListQuestionsInput {
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()
export class QuestionsService {
private readonly logger = new Logger(QuestionsService.name);
constructor(private readonly outbox: OutboxService) {}
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
@@ -119,4 +157,205 @@ export class QuestionsService {
{ 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 降级) ==========
/**
* 全文检索题目:优先使用 ESES 不可用时降级到 MySQL LIKE 查询。
*
* ES 检索策略:
* - 关键词 q 走 multi_matchcontent / 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 };
}
}

View File

@@ -4,6 +4,12 @@ import { HttpException } from "@nestjs/common";
const mockGetDb = vi.fn();
const mockGetNeo4jSession = 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", () => ({
getDb: () => mockGetDb(),
@@ -17,10 +23,23 @@ vi.mock("../../config/kafka.js", () => ({
isKafkaProducerConnected: () => mockIsKafkaConnected(),
}));
vi.mock("../../config/elasticsearch.js", () => ({
getEsClient: () => mockGetEsClient(),
QUESTION_INDEX_NAME: "content_questions",
}));
vi.mock("../../config/env.js", () => ({
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";
describe("HealthController", () => {
@@ -28,6 +47,9 @@ describe("HealthController", () => {
beforeEach(() => {
vi.clearAllMocks();
// 默认ES 未配置outbox pending = 0
mockGetEsClient.mockReturnValue(null);
mockCountPending.mockResolvedValue(0);
controller = new HealthController();
});
@@ -48,9 +70,16 @@ describe("HealthController", () => {
mockIsKafkaConnected.mockReturnValue(true);
const result = await controller.readiness();
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);
expect(names).toEqual(["mysql", "neo4j", "kafka"]);
expect(names).toEqual([
"mysql",
"neo4j",
"kafka",
"outbox",
"elasticsearch",
]);
expect(result.dependencies.every((d) => d.status === "ok")).toBe(true);
});
@@ -64,6 +93,17 @@ describe("HealthController", () => {
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 () => {
mockGetDb.mockReturnValue({
execute: vi.fn().mockRejectedValue(new Error("db down")),
@@ -79,5 +119,56 @@ describe("HealthController", () => {
mockIsKafkaConnected.mockReturnValue(false);
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");
});
});
});

View File

@@ -3,18 +3,28 @@ import { sql } from "drizzle-orm";
import { getDb } from "../../config/database.js";
import { getNeo4jSession } from "../../config/neo4j.js";
import { isKafkaProducerConnected } from "../../config/kafka.js";
import {
getEsClient,
QUESTION_INDEX_NAME,
} from "../../config/elasticsearch.js";
import { env } from "../../config/env.js";
import { outboxRepository } from "../outbox/outbox.repository.js";
import { contentOutboxPendingTotal } from "../observability/metrics.js";
const SERVICE_NAME = "content";
// P6.4: Outbox pending 数量阈值,超过则标记 degraded
const OUTBOX_PENDING_DEGRADED_THRESHOLD = 100;
interface DependencyCheck {
name: string;
status: "ok" | "error";
status: "ok" | "error" | "degraded";
error?: string;
details?: Record<string, unknown>;
}
interface ReadinessResponse {
status: "ok" | "error";
status: "ok" | "error" | "degraded";
service: string;
timestamp: string;
dependencies: DependencyCheck[];
@@ -89,10 +99,77 @@ export class HealthController {
});
}
const allOk = dependencies.every((d) => d.status === "ok");
const status = allOk ? "ok" : "error";
// 4. P6.4: Outbox pending count check> 100 标记 degraded
// 同时更新 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 时返回 503degraded 时仍返回 200服务可用但需关注
if (hasError) {
throw new HttpException(
{
status,

View File

@@ -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等
// 这些指标无需业务代码埋点prom-client 自动采集
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };
export {
registry as metricsRegistry,
contentOutboxPendingTotal,
contentNeo4jSyncLagMs,
contentEsSyncLagMs,
contentQuestionSearchTotal,
contentQuestionSearchLatencyMs,
// 兼容性别名:业务代码使用的简短名称
contentQuestionSearchTotal as questionSearchTotal,
contentQuestionSearchLatencyMs as questionSearchLatencyMs,
};

View File

@@ -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 { getDb } from "../../config/database.js";
import {
@@ -29,6 +29,15 @@ export class OutboxRepository {
.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> {
await getDb()
.update(outbox)

View 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();
});
});
});

View 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();

View File

@@ -44,6 +44,20 @@ export class TextbooksController {
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")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async getById(
@@ -72,4 +86,14 @@ export class TextbooksController {
await this.service.delete(id);
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 } };
}
}

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
textbooks,
@@ -35,6 +35,19 @@ export class TextbooksRepository {
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> {
await getDb().insert(textbooks).values(data);
}

View File

@@ -7,6 +7,7 @@ vi.mock("./textbooks.repository.js", () => ({
create: vi.fn(),
findById: vi.fn(),
find: vi.fn(),
findBySubjectAndGrade: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
@@ -213,4 +214,65 @@ describe("TextbooksService", () => {
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);
});
});
});

View File

@@ -101,4 +101,36 @@ export class TextbooksService {
{ 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);
}
}

View File

@@ -12,5 +12,5 @@
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
"exclude": ["node_modules", "dist", "test", "src/**/*.test.ts"]
}