feat(content): 修复服务并添加chapters/knowledge-points/questions模块

- database.ts 导出db常量替代getDb()函数

- env.ts JWT_SECRET/ES_URL/NEO4J_URL改optional加DEV_MODE

- neo4j.ts driver惰性创建+try/catch+connectionTimeout:3000

- health/lifecycle改用Drizzle原生查询

- textbooks.schema修复integer到int+导出NewTextbook类型

- 新建chapters/knowledge-points/questions三模块CRUD

- knowledge-points含Neo4j前置依赖图非阻塞查询

- content-init.sql创建4张表

端到端验证: textbooks/chapters/knowledge-points/questions全CRUD通过
This commit is contained in:
SpecialX
2026-07-09 08:52:15 +08:00
parent 033c083619
commit 921fe82771
29 changed files with 1031 additions and 246 deletions

View File

@@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import { Injectable, Logger } from "@nestjs/common";
import { knowledgePointsRepository } from "./knowledge-points.repository.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
import { getNeo4jSession } from "../config/neo4j.js";
import {
InternalError,
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateKnowledgePointInput {
chapterId: string;
title: string;
description?: string;
}
export interface UpdateKnowledgePointInput {
title?: string;
description?: string;
}
export interface PrerequisiteNode {
id: string;
title: string;
}
@Injectable()
export class KnowledgePointsService {
private readonly logger = new Logger(KnowledgePointsService.name);
async createKnowledgePoint(
input: CreateKnowledgePointInput,
): Promise<{ id: string }> {
if (!input.chapterId || !input.title) {
throw new ValidationError("chapterId, title are required");
}
const id = randomUUID();
await knowledgePointsRepository.create({
id,
chapterId: input.chapterId,
title: input.title,
description: input.description,
});
// Neo4j创建知识点节点。非阻塞——失败仅记录日志不影响 MySQL 写入。
await this.safeCreateNode(id, input.title);
return { id };
}
async getKnowledgePoint(id: string): Promise<KnowledgePoint> {
const kp = await knowledgePointsRepository.findById(id);
if (!kp) {
throw new NotFoundError("KnowledgePoint", id);
}
return kp;
}
async listByChapter(chapterId: string): Promise<KnowledgePoint[]> {
return knowledgePointsRepository.findByChapterId(chapterId);
}
async updateKnowledgePoint(
id: string,
data: UpdateKnowledgePointInput,
): Promise<void> {
await this.getKnowledgePoint(id);
await knowledgePointsRepository.update(id, data);
}
async deleteKnowledgePoint(id: string): Promise<void> {
await this.getKnowledgePoint(id);
await knowledgePointsRepository.delete(id);
}
async getPrerequisites(
knowledgePointId: string,
): Promise<PrerequisiteNode[]> {
const session = getNeo4jSession();
if (!session) {
return [];
}
try {
const result = await session.executeRead((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $id})<-[:PREREQUISITE_OF*1..5]-(prereq)
RETURN prereq.id as id, prereq.title as title`,
{ id: knowledgePointId },
),
);
return result.records.map((r): PrerequisiteNode => {
const rawId: unknown = r.get("id");
const rawTitle: unknown = r.get("title");
return {
id: typeof rawId === "string" ? rawId : String(rawId),
title: typeof rawTitle === "string" ? rawTitle : String(rawTitle),
};
});
} catch (err) {
this.logger.warn(
`Neo4j getPrerequisites failed: ${err instanceof Error ? err.message : String(err)}`,
);
return [];
} finally {
await session.close();
}
}
async addPrerequisite(id: string, prerequisiteId: string): Promise<void> {
if (id === prerequisiteId) {
throw new ValidationError(
"A knowledge point cannot be a prerequisite of itself",
);
}
// 先校验两个知识点在 MySQL 中都存在
await this.getKnowledgePoint(id);
await this.getKnowledgePoint(prerequisiteId);
const session = getNeo4jSession();
if (!session) {
throw new InternalError(
"Neo4j is not available, cannot add prerequisite",
);
}
try {
await session.executeWrite((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $kpId}), (prereq:KnowledgePoint {id: $prereqId})
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
{ kpId: id, prereqId: prerequisiteId },
),
);
} finally {
await session.close();
}
}
private async safeCreateNode(id: string, title: string): Promise<void> {
const session = getNeo4jSession();
if (!session) {
return;
}
try {
await session.executeWrite((tx) =>
tx.run("MERGE (kp:KnowledgePoint {id: $id, title: $title})", {
id,
title,
}),
);
} catch (err) {
this.logger.warn(
`Neo4j createNode failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
await session.close();
}
}
}