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 { const kp = await knowledgePointsRepository.findById(id); if (!kp) { throw new NotFoundError("KnowledgePoint", id); } return kp; } async listByChapter(chapterId: string): Promise { return knowledgePointsRepository.findByChapterId(chapterId); } async updateKnowledgePoint( id: string, data: UpdateKnowledgePointInput, ): Promise { await this.getKnowledgePoint(id); await knowledgePointsRepository.update(id, data); } async deleteKnowledgePoint(id: string): Promise { await this.getKnowledgePoint(id); await knowledgePointsRepository.delete(id); } async getPrerequisites( knowledgePointId: string, ): Promise { 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 { 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 { 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(); } } }