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

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