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:
@@ -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 } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 参数捕获。
|
||||
// 优先使用 ES;ES 不可用时服务层自动降级到 MySQL LIKE 查询。
|
||||
@Get("search")
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||
async search(@Query() query: unknown): Promise<{
|
||||
success: true;
|
||||
data: { items: Question[]; total: number; page: number; pageSize: number };
|
||||
}> {
|
||||
const input = searchQuestionsSchema.parse(query);
|
||||
const { items, total } = await this.service.searchQuestions(input);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
page: input.page,
|
||||
pageSize: input.pageSize,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 + total,total 为满足条件的总行数(不含分页)。
|
||||
*/
|
||||
async search(query: {
|
||||
q?: string;
|
||||
type?: string;
|
||||
difficulty?: number;
|
||||
knowledgePointId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ items: Question[]; total: number }> {
|
||||
const db = getDb();
|
||||
const conditions = [];
|
||||
|
||||
if (query.q) {
|
||||
const pattern = `%${query.q}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
like(questions.content, pattern),
|
||||
like(questions.answer, pattern),
|
||||
like(questions.explanation, pattern),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (query.type) {
|
||||
conditions.push(eq(questions.type, query.type));
|
||||
}
|
||||
if (query.difficulty !== undefined) {
|
||||
conditions.push(eq(questions.difficulty, query.difficulty));
|
||||
}
|
||||
if (query.knowledgePointId) {
|
||||
conditions.push(eq(questions.knowledgePointId, query.knowledgePointId));
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const page = query.page ?? 1;
|
||||
|
||||
const [items, totalRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(where)
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
db.select({ value: count() }).from(questions).where(where),
|
||||
]);
|
||||
|
||||
const total = totalRows[0]?.value ?? 0;
|
||||
return { items, total };
|
||||
}
|
||||
}
|
||||
|
||||
export const questionsRepository = new QuestionsRepository();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 降级) ==========
|
||||
|
||||
/**
|
||||
* 全文检索题目:优先使用 ES,ES 不可用时降级到 MySQL LIKE 查询。
|
||||
*
|
||||
* ES 检索策略:
|
||||
* - 关键词 q 走 multi_match(content / answer / explanation)
|
||||
* - type / difficulty / knowledgePointId 走 filter(精确匹配,利用缓存)
|
||||
* - 分页使用 from / size
|
||||
*
|
||||
* 降级策略:
|
||||
* - ES Client 未初始化(env.ES_URL 未配置)→ 直接走 MySQL
|
||||
* - ES 查询抛错(集群不可达等)→ log warn 并回退 MySQL
|
||||
*
|
||||
* P6.4: 同时记录 Prometheus 指标(search total + latency)。
|
||||
*/
|
||||
async searchQuestions(
|
||||
input: SearchQuestionsInput,
|
||||
): Promise<SearchQuestionsResult> {
|
||||
const page = input.page ?? 1;
|
||||
const pageSize = input.pageSize ?? 20;
|
||||
const startTime = Date.now();
|
||||
|
||||
const client = getEsClient();
|
||||
if (client) {
|
||||
try {
|
||||
const result = await this.searchViaEs(client, input, page, pageSize);
|
||||
this.recordSearchMetrics("es", startTime);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`ES search failed, falling back to MySQL: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await questionsRepository.search({
|
||||
q: input.q,
|
||||
type: input.type,
|
||||
difficulty: input.difficulty,
|
||||
knowledgePointId: input.knowledgePointId,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
this.recordSearchMetrics("mysql", startTime);
|
||||
return result;
|
||||
}
|
||||
|
||||
private recordSearchMetrics(source: string, startTime: number): void {
|
||||
const latency = Date.now() - startTime;
|
||||
questionSearchTotal.labels(source).inc();
|
||||
questionSearchLatencyMs.labels(source).observe(latency);
|
||||
}
|
||||
|
||||
private async searchViaEs(
|
||||
client: NonNullable<ReturnType<typeof getEsClient>>,
|
||||
input: SearchQuestionsInput,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<SearchQuestionsResult> {
|
||||
const must: unknown[] = [];
|
||||
const filter: unknown[] = [];
|
||||
|
||||
if (input.q) {
|
||||
must.push({
|
||||
multi_match: {
|
||||
query: input.q,
|
||||
fields: ["content", "answer", "explanation"],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.type) {
|
||||
filter.push({ term: { type: input.type } });
|
||||
}
|
||||
if (input.difficulty !== undefined) {
|
||||
filter.push({ term: { difficulty: input.difficulty } });
|
||||
}
|
||||
if (input.knowledgePointId) {
|
||||
filter.push({ term: { knowledge_point_id: input.knowledgePointId } });
|
||||
}
|
||||
|
||||
const boolClause: Record<string, unknown> = {};
|
||||
if (must.length > 0) boolClause.must = must;
|
||||
if (filter.length > 0) boolClause.filter = filter;
|
||||
|
||||
const response = await client.search({
|
||||
index: QUESTION_INDEX_NAME,
|
||||
from: (page - 1) * pageSize,
|
||||
size: pageSize,
|
||||
query:
|
||||
Object.keys(boolClause).length > 0
|
||||
? { bool: boolClause }
|
||||
: { match_all: {} },
|
||||
});
|
||||
|
||||
const hits = response.hits?.hits ?? [];
|
||||
const total =
|
||||
typeof response.hits?.total === "number"
|
||||
? response.hits.total
|
||||
: (response.hits?.total?.value ?? 0);
|
||||
|
||||
// ES search 默认 TDocument=unknown,此处从 unknown 转换获取 question_id
|
||||
const ids = hits
|
||||
.map((h) => {
|
||||
const source = h._source as Record<string, unknown> | undefined;
|
||||
return source?.question_id;
|
||||
})
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
|
||||
if (ids.length === 0) {
|
||||
return { items: [], total };
|
||||
}
|
||||
|
||||
// 从 MySQL 读取完整记录(ES 只存储检索字段,回查 DB 获取 options/metadata 等全字段)
|
||||
const items: Question[] = [];
|
||||
for (const id of ids) {
|
||||
const q = await questionsRepository.findById(id);
|
||||
if (q) items.push(q);
|
||||
}
|
||||
return { items, total };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user