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

@@ -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
}
}