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:
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",
|
||||
});
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user