- 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通过
165 lines
4.4 KiB
TypeScript
165 lines
4.4 KiB
TypeScript
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();
|
||
}
|
||
}
|
||
}
|