From 921fe8277113ceb0df775e8a2a5dbc5f2f9e1f93 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:52:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E4=BF=AE=E5=A4=8D=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=B9=B6=E6=B7=BB=E5=8A=A0chapters/knowledge-points/q?= =?UTF-8?q?uestions=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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通过 --- scripts/content-init.sql | 47 +++++ services/content/src/app.module.ts | 16 +- .../src/chapters/chapters.controller.ts | 61 +++++++ .../content/src/chapters/chapters.module.ts | 10 ++ .../src/chapters/chapters.repository.ts | 35 ++++ .../content/src/chapters/chapters.schema.ts | 5 + .../content/src/chapters/chapters.service.ts | 62 +++++++ services/content/src/config/database.ts | 30 ++-- services/content/src/config/env.ts | 30 ++-- services/content/src/config/neo4j.ts | 41 ++++- .../knowledge-points.controller.ts | 79 +++++++++ .../knowledge-points.module.ts | 10 ++ .../knowledge-points.repository.ts | 42 +++++ .../knowledge-points.schema.ts | 5 + .../knowledge-points.service.ts | 164 ++++++++++++++++++ services/content/src/main.ts | 39 +++-- .../src/questions/questions.controller.ts | 61 +++++++ .../content/src/questions/questions.module.ts | 10 ++ .../src/questions/questions.repository.ts | 39 +++++ .../content/src/questions/questions.schema.ts | 23 +++ .../src/questions/questions.service.ts | 83 +++++++++ .../src/shared/errors/global-error.filter.ts | 43 +++-- .../src/shared/health/health.controller.ts | 39 ++--- .../src/shared/health/health.module.ts | 20 +-- .../src/shared/lifecycle/lifecycle.service.ts | 59 ++----- .../src/textbooks/textbooks.controller.ts | 58 +++++-- .../content/src/textbooks/textbooks.module.ts | 7 +- .../content/src/textbooks/textbooks.schema.ts | 50 +++--- .../src/textbooks/textbooks.service.ts | 109 ++++++------ 29 files changed, 1031 insertions(+), 246 deletions(-) create mode 100644 scripts/content-init.sql create mode 100644 services/content/src/chapters/chapters.controller.ts create mode 100644 services/content/src/chapters/chapters.module.ts create mode 100644 services/content/src/chapters/chapters.repository.ts create mode 100644 services/content/src/chapters/chapters.schema.ts create mode 100644 services/content/src/chapters/chapters.service.ts create mode 100644 services/content/src/knowledge-points/knowledge-points.controller.ts create mode 100644 services/content/src/knowledge-points/knowledge-points.module.ts create mode 100644 services/content/src/knowledge-points/knowledge-points.repository.ts create mode 100644 services/content/src/knowledge-points/knowledge-points.schema.ts create mode 100644 services/content/src/knowledge-points/knowledge-points.service.ts create mode 100644 services/content/src/questions/questions.controller.ts create mode 100644 services/content/src/questions/questions.module.ts create mode 100644 services/content/src/questions/questions.repository.ts create mode 100644 services/content/src/questions/questions.schema.ts create mode 100644 services/content/src/questions/questions.service.ts diff --git a/scripts/content-init.sql b/scripts/content-init.sql new file mode 100644 index 0000000..b942fae --- /dev/null +++ b/scripts/content-init.sql @@ -0,0 +1,47 @@ +-- Content 服务数据库初始化脚本 +-- 表:content_textbooks / content_chapters / content_knowledge_points / content_questions +-- 表结构与 services/content/src 下的 Drizzle schema 对齐 + +CREATE TABLE IF NOT EXISTS content_textbooks ( + id CHAR(36) NOT NULL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + subject_id CHAR(36) NOT NULL, + grade_id CHAR(36) NOT NULL, + version VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_textbooks_subject_id (subject_id), + INDEX idx_textbooks_grade_id (grade_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS content_chapters ( + id CHAR(36) NOT NULL PRIMARY KEY, + textbook_id CHAR(36) NOT NULL, + title VARCHAR(200) NOT NULL, + order_num INT NOT NULL, + parent_id CHAR(36), + INDEX idx_chapters_textbook_id (textbook_id), + INDEX idx_chapters_parent_id (parent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS content_knowledge_points ( + id CHAR(36) NOT NULL PRIMARY KEY, + chapter_id CHAR(36) NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT, + INDEX idx_knowledge_points_chapter_id (chapter_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS content_questions ( + id CHAR(36) NOT NULL PRIMARY KEY, + knowledge_point_id CHAR(36) NOT NULL, + type VARCHAR(50) NOT NULL, + content TEXT NOT NULL, + answer TEXT, + explanation TEXT, + difficulty INT DEFAULT 3, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_questions_knowledge_point_id (knowledge_point_id), + INDEX idx_questions_type (type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/services/content/src/app.module.ts b/services/content/src/app.module.ts index 2f448f8..a620fcd 100644 --- a/services/content/src/app.module.ts +++ b/services/content/src/app.module.ts @@ -1,7 +1,17 @@ -import { Module } from '@nestjs/common'; -import { TextbooksModule } from './textbooks/textbooks.module.js'; +import { Module } from "@nestjs/common"; +import { TextbooksModule } from "./textbooks/textbooks.module.js"; +import { ChaptersModule } from "./chapters/chapters.module.js"; +import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js"; +import { QuestionsModule } from "./questions/questions.module.js"; +import { HealthModule } from "./shared/health/health.module.js"; @Module({ - imports: [TextbooksModule], + imports: [ + TextbooksModule, + ChaptersModule, + KnowledgePointsModule, + QuestionsModule, + HealthModule, + ], }) export class AppModule {} diff --git a/services/content/src/chapters/chapters.controller.ts b/services/content/src/chapters/chapters.controller.ts new file mode 100644 index 0000000..878cf61 --- /dev/null +++ b/services/content/src/chapters/chapters.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from "@nestjs/common"; +import { + ChaptersService, + type CreateChapterInput, + type UpdateChapterInput, +} from "./chapters.service.js"; +import type { Chapter } from "./chapters.schema.js"; + +@Controller("chapters") +export class ChaptersController { + constructor(private readonly service: ChaptersService) {} + + @Post() + async create( + @Body() body: CreateChapterInput, + ): Promise<{ success: true; data: { id: string } }> { + const result = await this.service.createChapter(body); + return { success: true, data: result }; + } + + @Get("textbook/:textbookId") + async listByTextbook( + @Param("textbookId") textbookId: string, + ): Promise<{ success: true; data: Chapter[] }> { + const data = await this.service.listChaptersByTextbook(textbookId); + return { success: true, data }; + } + + @Get(":id") + async getById( + @Param("id") id: string, + ): Promise<{ success: true; data: Chapter }> { + const data = await this.service.getChapter(id); + return { success: true, data }; + } + + @Put(":id") + async update( + @Param("id") id: string, + @Body() body: UpdateChapterInput, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.updateChapter(id, body); + return { success: true, data: { success: true } }; + } + + @Delete(":id") + async remove( + @Param("id") id: string, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.deleteChapter(id); + return { success: true, data: { success: true } }; + } +} diff --git a/services/content/src/chapters/chapters.module.ts b/services/content/src/chapters/chapters.module.ts new file mode 100644 index 0000000..ae6c74a --- /dev/null +++ b/services/content/src/chapters/chapters.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ChaptersController } from "./chapters.controller.js"; +import { ChaptersService } from "./chapters.service.js"; + +@Module({ + controllers: [ChaptersController], + providers: [ChaptersService], + exports: [ChaptersService], +}) +export class ChaptersModule {} diff --git a/services/content/src/chapters/chapters.repository.ts b/services/content/src/chapters/chapters.repository.ts new file mode 100644 index 0000000..c62ee3f --- /dev/null +++ b/services/content/src/chapters/chapters.repository.ts @@ -0,0 +1,35 @@ +import { eq } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js"; + +export class ChaptersRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(chapters) + .where(eq(chapters.id, id)) + .limit(1); + return result; + } + + async findByTextbookId(textbookId: string): Promise { + return db + .select() + .from(chapters) + .where(eq(chapters.textbookId, textbookId)); + } + + async create(data: NewChapter): Promise { + await db.insert(chapters).values(data); + } + + async update(id: string, data: Partial): Promise { + await db.update(chapters).set(data).where(eq(chapters.id, id)); + } + + async delete(id: string): Promise { + await db.delete(chapters).where(eq(chapters.id, id)); + } +} + +export const chaptersRepository = new ChaptersRepository(); diff --git a/services/content/src/chapters/chapters.schema.ts b/services/content/src/chapters/chapters.schema.ts new file mode 100644 index 0000000..70da396 --- /dev/null +++ b/services/content/src/chapters/chapters.schema.ts @@ -0,0 +1,5 @@ +export { + chapters, + type Chapter, + type NewChapter, +} from "../textbooks/textbooks.schema.js"; diff --git a/services/content/src/chapters/chapters.service.ts b/services/content/src/chapters/chapters.service.ts new file mode 100644 index 0000000..507db64 --- /dev/null +++ b/services/content/src/chapters/chapters.service.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { chaptersRepository } from "./chapters.repository.js"; +import type { Chapter } from "./chapters.schema.js"; +import { + NotFoundError, + ValidationError, +} from "../shared/errors/application-error.js"; + +export interface CreateChapterInput { + textbookId: string; + title: string; + order: number; + parentId?: string; +} + +export interface UpdateChapterInput { + title?: string; + order?: number; + parentId?: string; +} + +@Injectable() +export class ChaptersService { + async createChapter(input: CreateChapterInput): Promise<{ id: string }> { + if (!input.textbookId || !input.title || input.order === undefined) { + throw new ValidationError("textbookId, title, order are required"); + } + + const id = randomUUID(); + await chaptersRepository.create({ + id, + textbookId: input.textbookId, + title: input.title, + order: input.order, + parentId: input.parentId, + }); + return { id }; + } + + async getChapter(id: string): Promise { + const chapter = await chaptersRepository.findById(id); + if (!chapter) { + throw new NotFoundError("Chapter", id); + } + return chapter; + } + + async listChaptersByTextbook(textbookId: string): Promise { + return chaptersRepository.findByTextbookId(textbookId); + } + + async updateChapter(id: string, data: UpdateChapterInput): Promise { + await this.getChapter(id); + await chaptersRepository.update(id, data); + } + + async deleteChapter(id: string): Promise { + await this.getChapter(id); + await chaptersRepository.delete(id); + } +} diff --git a/services/content/src/config/database.ts b/services/content/src/config/database.ts index d6550c8..c040316 100644 --- a/services/content/src/config/database.ts +++ b/services/content/src/config/database.ts @@ -1,24 +1,16 @@ -import { drizzle } from 'drizzle-orm/mysql2'; -import mysql from 'mysql2/promise'; -import { env } from './env.js'; +import { drizzle } from "drizzle-orm/mysql2"; +import mysql from "mysql2/promise"; +import { env } from "./env.js"; -let pool: mysql.Pool | null = null; +const pool = mysql.createPool({ + uri: env.DATABASE_URL, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, +}); -export function getDb() { - if (!pool) { - pool = mysql.createPool({ - uri: env.DATABASE_URL, - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0, - }); - } - return drizzle(pool); -} +export const db = drizzle(pool); export async function closeDb(): Promise { - if (pool) { - await pool.end(); - pool = null; - } + await pool.end(); } diff --git a/services/content/src/config/env.ts b/services/content/src/config/env.ts index 4244915..c9be5c2 100644 --- a/services/content/src/config/env.ts +++ b/services/content/src/config/env.ts @@ -1,17 +1,22 @@ -import { z } from 'zod'; +import { z } from "zod"; const envSchema = z.object({ - PORT: z.string().default('3005'), + PORT: z.string().default("3005"), DATABASE_URL: z.string().url(), REDIS_URL: z.string().url().optional(), - NEO4J_URL: z.string().url(), - NEO4J_PASSWORD: z.string(), - ES_URL: z.string().url(), - JWT_SECRET: z.string(), - JWT_ISSUER: z.string().default('next-edu-cloud'), + NEO4J_URL: z.string().url().optional(), + NEO4J_PASSWORD: z.string().optional(), + ES_URL: z.string().url().optional(), + JWT_SECRET: z.string().optional(), + JWT_ISSUER: z.string().default("next-edu-cloud"), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), - LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace"]) + .default("info"), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + DEV_MODE: z.string().optional().default("false"), }); export type Env = z.infer; @@ -19,8 +24,11 @@ export type Env = z.infer; export function loadEnv(): Env { const result = envSchema.safeParse(process.env); if (!result.success) { - console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors); - throw new Error('Invalid environment configuration'); + console.error( + "❌ Invalid environment variables:", + result.error.flatten().fieldErrors, + ); + throw new Error("Invalid environment configuration"); } return result.data; } diff --git a/services/content/src/config/neo4j.ts b/services/content/src/config/neo4j.ts index 7c63c81..30bcfc9 100644 --- a/services/content/src/config/neo4j.ts +++ b/services/content/src/config/neo4j.ts @@ -1,11 +1,38 @@ -import neo4j from 'neo4j-driver'; -import { env } from './env.js'; +import neo4j from "neo4j-driver"; +import type { Driver, Session } from "neo4j-driver"; +import { env } from "./env.js"; -export const neo4jDriver = neo4j.driver( - env.NEO4J_URL, - neo4j.auth.basic('neo4j', env.NEO4J_PASSWORD) -); +// Neo4j driver 创建为惰性初始化:未配置 NEO4J_URL / NEO4J_PASSWORD 时 +// driver 保持 null,服务仍可正常启动。所有依赖 Neo4j 的查询在 +// driver 为 null 时返回空结果或抛出可控错误,不会阻塞主流程。 +let driver: Driver | null = null; + +try { + if (env.NEO4J_URL && env.NEO4J_PASSWORD) { + driver = neo4j.driver( + env.NEO4J_URL, + neo4j.auth.basic("neo4j", env.NEO4J_PASSWORD), + // 连接超时 3s:Neo4j 不可用时快速失败,避免拖慢 HTTP 响应 + { connectionTimeout: 3000, maxConnectionLifetime: 60_000 }, + ); + } +} catch (err) { + console.warn( + "Neo4j driver init failed, running without Neo4j:", + err instanceof Error ? err.message : String(err), + ); + driver = null; +} + +export function getNeo4jSession(): Session | null { + if (!driver) { + return null; + } + return driver.session(); +} export async function closeNeo4j(): Promise { - await neo4jDriver.close(); + if (driver) { + await driver.close(); + } } diff --git a/services/content/src/knowledge-points/knowledge-points.controller.ts b/services/content/src/knowledge-points/knowledge-points.controller.ts new file mode 100644 index 0000000..9c47c18 --- /dev/null +++ b/services/content/src/knowledge-points/knowledge-points.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from "@nestjs/common"; +import { + KnowledgePointsService, + type CreateKnowledgePointInput, + type UpdateKnowledgePointInput, + type PrerequisiteNode, +} from "./knowledge-points.service.js"; +import type { KnowledgePoint } from "./knowledge-points.schema.js"; + +@Controller("knowledge-points") +export class KnowledgePointsController { + constructor(private readonly service: KnowledgePointsService) {} + + @Post() + async create( + @Body() body: CreateKnowledgePointInput, + ): Promise<{ success: true; data: { id: string } }> { + const result = await this.service.createKnowledgePoint(body); + return { success: true, data: result }; + } + + @Get("chapter/:chapterId") + async listByChapter( + @Param("chapterId") chapterId: string, + ): Promise<{ success: true; data: KnowledgePoint[] }> { + const data = await this.service.listByChapter(chapterId); + return { success: true, data }; + } + + @Get(":id/prerequisites") + async getPrerequisites( + @Param("id") id: string, + ): Promise<{ success: true; data: PrerequisiteNode[] }> { + const data = await this.service.getPrerequisites(id); + return { success: true, data }; + } + + @Get(":id") + async getById( + @Param("id") id: string, + ): Promise<{ success: true; data: KnowledgePoint }> { + const data = await this.service.getKnowledgePoint(id); + return { success: true, data }; + } + + @Post(":id/prerequisites/:prerequisiteId") + async addPrerequisite( + @Param("id") id: string, + @Param("prerequisiteId") prerequisiteId: string, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.addPrerequisite(id, prerequisiteId); + return { success: true, data: { success: true } }; + } + + @Put(":id") + async update( + @Param("id") id: string, + @Body() body: UpdateKnowledgePointInput, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.updateKnowledgePoint(id, body); + return { success: true, data: { success: true } }; + } + + @Delete(":id") + async remove( + @Param("id") id: string, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.deleteKnowledgePoint(id); + return { success: true, data: { success: true } }; + } +} diff --git a/services/content/src/knowledge-points/knowledge-points.module.ts b/services/content/src/knowledge-points/knowledge-points.module.ts new file mode 100644 index 0000000..d86a472 --- /dev/null +++ b/services/content/src/knowledge-points/knowledge-points.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { KnowledgePointsController } from "./knowledge-points.controller.js"; +import { KnowledgePointsService } from "./knowledge-points.service.js"; + +@Module({ + controllers: [KnowledgePointsController], + providers: [KnowledgePointsService], + exports: [KnowledgePointsService], +}) +export class KnowledgePointsModule {} diff --git a/services/content/src/knowledge-points/knowledge-points.repository.ts b/services/content/src/knowledge-points/knowledge-points.repository.ts new file mode 100644 index 0000000..e2a8b63 --- /dev/null +++ b/services/content/src/knowledge-points/knowledge-points.repository.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { + knowledgePoints, + type KnowledgePoint, + type NewKnowledgePoint, +} from "./knowledge-points.schema.js"; + +export class KnowledgePointsRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(knowledgePoints) + .where(eq(knowledgePoints.id, id)) + .limit(1); + return result; + } + + async findByChapterId(chapterId: string): Promise { + return db + .select() + .from(knowledgePoints) + .where(eq(knowledgePoints.chapterId, chapterId)); + } + + async create(data: NewKnowledgePoint): Promise { + await db.insert(knowledgePoints).values(data); + } + + async update(id: string, data: Partial): Promise { + await db + .update(knowledgePoints) + .set(data) + .where(eq(knowledgePoints.id, id)); + } + + async delete(id: string): Promise { + await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)); + } +} + +export const knowledgePointsRepository = new KnowledgePointsRepository(); diff --git a/services/content/src/knowledge-points/knowledge-points.schema.ts b/services/content/src/knowledge-points/knowledge-points.schema.ts new file mode 100644 index 0000000..5eb3472 --- /dev/null +++ b/services/content/src/knowledge-points/knowledge-points.schema.ts @@ -0,0 +1,5 @@ +export { + knowledgePoints, + type KnowledgePoint, + type NewKnowledgePoint, +} from "../textbooks/textbooks.schema.js"; diff --git a/services/content/src/knowledge-points/knowledge-points.service.ts b/services/content/src/knowledge-points/knowledge-points.service.ts new file mode 100644 index 0000000..2e48b1d --- /dev/null +++ b/services/content/src/knowledge-points/knowledge-points.service.ts @@ -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 { + 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(); + } + } +} diff --git a/services/content/src/main.ts b/services/content/src/main.ts index b784f40..bc95381 100644 --- a/services/content/src/main.ts +++ b/services/content/src/main.ts @@ -1,31 +1,46 @@ -import 'reflect-metadata'; -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module.js'; -import { GlobalErrorFilter } from './shared/errors/global-error.filter.js'; -import { initTracer, shutdownTracer } from './shared/observability/tracer.js'; -import { env } from './config/env.js'; -import { logger } from './shared/observability/logger.js'; +import "reflect-metadata"; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module.js"; +import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js"; +import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; +import { env } from "./config/env.js"; +import { closeDb } from "./config/database.js"; +import { closeNeo4j } from "./config/neo4j.js"; +import { logger } from "./shared/observability/logger.js"; async function bootstrap(): Promise { initTracer(); const app = await NestFactory.create(AppModule, { - logger: ['log', 'error', 'warn'], + logger: ["log", "error", "warn"], }); app.useGlobalFilters(new GlobalErrorFilter()); app.enableShutdownHooks(); await app.listen(env.PORT); - logger.info({ port: env.PORT }, 'Content service started'); + logger.info({ port: env.PORT }, "Content service started"); - process.on('SIGTERM', async () => { - await app.close(); + process.on("SIGTERM", async () => { + logger.info("SIGTERM received, shutting down gracefully..."); + await closeNeo4j(); + await closeDb(); await shutdownTracer(); + await app.close(); + process.exit(0); + }); + + process.on("SIGINT", async () => { + logger.info("SIGINT received, shutting down gracefully..."); + await closeNeo4j(); + await closeDb(); + await shutdownTracer(); + await app.close(); + process.exit(0); }); } bootstrap().catch((err: unknown) => { - logger.error({ err }, 'Failed to start content service'); + logger.error({ err }, "Failed to start content service"); process.exit(1); }); diff --git a/services/content/src/questions/questions.controller.ts b/services/content/src/questions/questions.controller.ts new file mode 100644 index 0000000..ebdf0d0 --- /dev/null +++ b/services/content/src/questions/questions.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from "@nestjs/common"; +import { + QuestionsService, + type CreateQuestionInput, + type UpdateQuestionInput, +} from "./questions.service.js"; +import type { Question } from "./questions.schema.js"; + +@Controller("questions") +export class QuestionsController { + constructor(private readonly service: QuestionsService) {} + + @Post() + async create( + @Body() body: CreateQuestionInput, + ): Promise<{ success: true; data: { id: string } }> { + const result = await this.service.createQuestion(body); + return { success: true, data: result }; + } + + @Get("knowledge-point/:knowledgePointId") + async listByKnowledgePoint( + @Param("knowledgePointId") knowledgePointId: string, + ): Promise<{ success: true; data: Question[] }> { + const data = await this.service.listByKnowledgePoint(knowledgePointId); + return { success: true, data }; + } + + @Get(":id") + async getById( + @Param("id") id: string, + ): Promise<{ success: true; data: Question }> { + const data = await this.service.getQuestion(id); + return { success: true, data }; + } + + @Put(":id") + async update( + @Param("id") id: string, + @Body() body: UpdateQuestionInput, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.updateQuestion(id, body); + return { success: true, data: { success: true } }; + } + + @Delete(":id") + async remove( + @Param("id") id: string, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.deleteQuestion(id); + return { success: true, data: { success: true } }; + } +} diff --git a/services/content/src/questions/questions.module.ts b/services/content/src/questions/questions.module.ts new file mode 100644 index 0000000..d0fe560 --- /dev/null +++ b/services/content/src/questions/questions.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { QuestionsController } from "./questions.controller.js"; +import { QuestionsService } from "./questions.service.js"; + +@Module({ + controllers: [QuestionsController], + providers: [QuestionsService], + exports: [QuestionsService], +}) +export class QuestionsModule {} diff --git a/services/content/src/questions/questions.repository.ts b/services/content/src/questions/questions.repository.ts new file mode 100644 index 0000000..fe8af73 --- /dev/null +++ b/services/content/src/questions/questions.repository.ts @@ -0,0 +1,39 @@ +import { eq } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { + questions, + type Question, + type NewQuestion, +} from "./questions.schema.js"; + +export class QuestionsRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(questions) + .where(eq(questions.id, id)) + .limit(1); + return result; + } + + async findByKnowledgePointId(knowledgePointId: string): Promise { + return db + .select() + .from(questions) + .where(eq(questions.knowledgePointId, knowledgePointId)); + } + + async create(data: NewQuestion): Promise { + await db.insert(questions).values(data); + } + + async update(id: string, data: Partial): Promise { + await db.update(questions).set(data).where(eq(questions.id, id)); + } + + async delete(id: string): Promise { + await db.delete(questions).where(eq(questions.id, id)); + } +} + +export const questionsRepository = new QuestionsRepository(); diff --git a/services/content/src/questions/questions.schema.ts b/services/content/src/questions/questions.schema.ts new file mode 100644 index 0000000..b20673d --- /dev/null +++ b/services/content/src/questions/questions.schema.ts @@ -0,0 +1,23 @@ +import { + mysqlTable, + char, + varchar, + text, + int, + timestamp, +} from "drizzle-orm/mysql-core"; + +export const questions = mysqlTable("content_questions", { + id: char("id", { length: 36 }).notNull().primaryKey(), + knowledgePointId: char("knowledge_point_id", { length: 36 }).notNull(), + type: varchar("type", { length: 50 }).notNull(), + content: text("content").notNull(), + answer: text("answer"), + explanation: text("explanation"), + difficulty: int("difficulty").default(3), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), +}); + +export type Question = typeof questions.$inferSelect; +export type NewQuestion = typeof questions.$inferInsert; diff --git a/services/content/src/questions/questions.service.ts b/services/content/src/questions/questions.service.ts new file mode 100644 index 0000000..ecc903f --- /dev/null +++ b/services/content/src/questions/questions.service.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { questionsRepository } from "./questions.repository.js"; +import type { Question } from "./questions.schema.js"; +import { + NotFoundError, + ValidationError, +} from "../shared/errors/application-error.js"; + +export interface CreateQuestionInput { + knowledgePointId: string; + type: string; + content: string; + answer?: string; + explanation?: string; + difficulty?: number; +} + +export interface UpdateQuestionInput { + type?: string; + content?: string; + answer?: string; + explanation?: string; + difficulty?: number; +} + +const VALID_TYPES = new Set([ + "single_choice", + "multiple_choice", + "short_answer", + "essay", +]); + +@Injectable() +export class QuestionsService { + async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> { + if (!input.knowledgePointId || !input.type || !input.content) { + throw new ValidationError("knowledgePointId, type, content are required"); + } + if (!VALID_TYPES.has(input.type)) { + throw new ValidationError( + `Invalid question type: ${input.type}. Must be one of: ${[...VALID_TYPES].join(", ")}`, + ); + } + + const id = randomUUID(); + await questionsRepository.create({ + id, + knowledgePointId: input.knowledgePointId, + type: input.type, + content: input.content, + answer: input.answer, + explanation: input.explanation, + difficulty: input.difficulty, + }); + return { id }; + } + + async getQuestion(id: string): Promise { + const question = await questionsRepository.findById(id); + if (!question) { + throw new NotFoundError("Question", id); + } + return question; + } + + async listByKnowledgePoint(knowledgePointId: string): Promise { + return questionsRepository.findByKnowledgePointId(knowledgePointId); + } + + async updateQuestion(id: string, data: UpdateQuestionInput): Promise { + await this.getQuestion(id); + if (data.type && !VALID_TYPES.has(data.type)) { + throw new ValidationError(`Invalid question type: ${data.type}`); + } + await questionsRepository.update(id, data); + } + + async deleteQuestion(id: string): Promise { + await this.getQuestion(id); + await questionsRepository.delete(id); + } +} diff --git a/services/content/src/shared/errors/global-error.filter.ts b/services/content/src/shared/errors/global-error.filter.ts index cee67f8..604a098 100644 --- a/services/content/src/shared/errors/global-error.filter.ts +++ b/services/content/src/shared/errors/global-error.filter.ts @@ -1,7 +1,12 @@ -import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common'; -import { Request, Response } from 'express'; -import { ZodError } from 'zod'; -import { ApplicationError } from './application-error.js'; +import { + Catch, + ExceptionFilter, + ArgumentsHost, + HttpException, + Logger, +} from "@nestjs/common"; +import { ZodError } from "zod"; +import { ApplicationError } from "./application-error.js"; @Catch() export class GlobalErrorFilter implements ExceptionFilter { @@ -9,10 +14,13 @@ export class GlobalErrorFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); + // NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例, + // 但 content 服务未引入 @types/express,此处按 core-edu 模式不显式标注类型。 + const response = ctx.getResponse(); + const request = ctx.getRequest(); - const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; + const traceId = + (request.headers["x-request-id"] as string | undefined) ?? "unknown"; let statusCode = 500; let body: Record; @@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter { body = { success: false, error: { - code: 'CONTENT_VALIDATION_ERROR', - message: 'Validation failed', + code: "CONTENT_VALIDATION_ERROR", + message: "Validation failed", details: exception.flatten(), traceId, }, @@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter { body = { success: false, error: { - code: 'HTTP_ERROR', + code: "HTTP_ERROR", message, traceId, }, @@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter { body = { success: false, error: { - code: 'INTERNAL_ERROR', - message: 'An unexpected error occurred', + code: "INTERNAL_ERROR", + message: "An unexpected error occurred", traceId, }, }; @@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter { response.status(statusCode).json(body); } - private extractHttpMessage(res: string | object, exception: HttpException): string { - if (typeof res === 'string') { + private extractHttpMessage( + res: string | object, + exception: HttpException, + ): string { + if (typeof res === "string") { return res; } - if (res && typeof res === 'object' && 'message' in res) { + if (res && typeof res === "object" && "message" in res) { // 从 HttpException 响应体收窄类型(NestJS 约定包含 message 字段) const msg = (res as { message: unknown }).message; - return typeof msg === 'string' ? msg : exception.message; + return typeof msg === "string" ? msg : exception.message; } return exception.message; } diff --git a/services/content/src/shared/health/health.controller.ts b/services/content/src/shared/health/health.controller.ts index bbcba57..4839b5a 100644 --- a/services/content/src/shared/health/health.controller.ts +++ b/services/content/src/shared/health/health.controller.ts @@ -1,46 +1,41 @@ -import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common"; +import { sql } from "drizzle-orm"; +import { db } from "../../config/database.js"; -const SERVICE_NAME = 'content'; +const SERVICE_NAME = "content"; -/** - * 健康检查端点。 - * - * - GET /healthz:liveness,仅返回进程存活,不检查依赖。 - * - GET /readyz:readiness,检查 DB 连接,失败返回 503。 - * - * 不需要鉴权,必须在路由白名单中放行。本控制器内容在 iam / core-edu / - * content / msg / classes 五个 NestJS 服务中一致,仅 SERVICE_NAME 不同。 - */ @Controller() export class HealthController { - constructor(private readonly dataSource: DataSource) {} - - @Get('healthz') + @Get("healthz") liveness(): { status: string; service: string; timestamp: string } { return { - status: 'ok', + status: "ok", service: SERVICE_NAME, timestamp: new Date().toISOString(), }; } - @Get('readyz') - async readiness(): Promise<{ status: string; service: string; timestamp: string }> { + @Get("readyz") + async readiness(): Promise<{ + status: string; + service: string; + timestamp: string; + }> { try { - await this.dataSource.query('SELECT 1'); + await db.execute(sql`SELECT 1`); return { - status: 'ok', + status: "ok", service: SERVICE_NAME, timestamp: new Date().toISOString(), }; } catch (error) { throw new HttpException( { - status: 'error', + status: "error", service: SERVICE_NAME, timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'database unreachable', + error: + error instanceof Error ? error.message : "database unreachable", }, HttpStatus.SERVICE_UNAVAILABLE, ); diff --git a/services/content/src/shared/health/health.module.ts b/services/content/src/shared/health/health.module.ts index 23a576a..8c202b9 100644 --- a/services/content/src/shared/health/health.module.ts +++ b/services/content/src/shared/health/health.module.ts @@ -1,22 +1,6 @@ -import { Module } from '@nestjs/common'; -import { HealthController } from './health.controller'; +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller.js"; -/** - * 健康检查模块。 - * - * 集成说明(不修改 app.module.ts,仅在 README 注释说明): - * - * 在 `app.module.ts` 的 imports 数组中加入 `HealthModule`: - * - * ```ts - * import { HealthModule } from './shared/health/health.module'; - * - * @Module({ imports: [ ..., HealthModule ], ... }) - * export class AppModule {} - * ``` - * - * DataSource 由 `TypeOrmModule.forRoot(...)` 提供,本模块无需额外 provider。 - */ @Module({ controllers: [HealthController], }) diff --git a/services/content/src/shared/lifecycle/lifecycle.service.ts b/services/content/src/shared/lifecycle/lifecycle.service.ts index 7bf56df..ed1b4d6 100644 --- a/services/content/src/shared/lifecycle/lifecycle.service.ts +++ b/services/content/src/shared/lifecycle/lifecycle.service.ts @@ -1,63 +1,24 @@ -import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import type { Redis } from 'ioredis'; -import type { Producer } from 'kafkajs'; +import { Injectable, Logger } from "@nestjs/common"; +import { closeDb } from "../../config/database.js"; +import { closeNeo4j } from "../../config/neo4j.js"; -const SERVICE_NAME = 'content'; +const SERVICE_NAME = "content"; -/** - * 优雅停机服务。 - * - * 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()` - * 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。 - * K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。 - * - * 关闭顺序:Kafka producer → Redis → DataSource。 - * 先停外部消息生产避免新事件,再关缓存,最后关 DB。 - * 注:content 服务额外使用 S3/MinIO 客户端,其连接由 SDK 内部管理,无需显式关闭。 - * - * 集成说明(不修改 app.module.ts,仅在 README 注释说明): - * - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。 - * - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。 - * - * 依赖注入 token 约定(需与各服务 provider 注册一致): - * - DataSource:由 `TypeOrmModule.forRoot()` 提供。 - * - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。 - * - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。 - */ @Injectable() -export class LifecycleService implements OnModuleInit, OnApplicationShutdown { +export class LifecycleService { private readonly logger = new Logger(LifecycleService.name); - constructor( - private readonly dataSource: DataSource, - @Inject('REDIS_CLIENT') private readonly redis: Redis, - @Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer, - ) {} - - onModuleInit(): void { - this.logger.log(`service ${SERVICE_NAME} module initialized`); - } - async onApplicationShutdown(signal?: string): Promise { this.logger.log( - `service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`, + `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, ); - - await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect()); - await this.safeDisconnect('redis', () => this.redis.quit()); - await this.safeDisconnect('datasource', () => this.dataSource.destroy()); - - this.logger.log(`service ${SERVICE_NAME} shutdown complete`); - } - - private async safeDisconnect(name: string, fn: () => Promise): Promise { try { - await fn(); - this.logger.log(`${name} closed`); + await closeNeo4j(); + await closeDb(); + this.logger.log(`service ${SERVICE_NAME} shutdown complete`); } catch (error) { this.logger.error( - `${name} close failed: ${error instanceof Error ? error.message : String(error)}`, + `shutdown failed: ${error instanceof Error ? error.message : String(error)}`, ); } } diff --git a/services/content/src/textbooks/textbooks.controller.ts b/services/content/src/textbooks/textbooks.controller.ts index 2efa1cb..4d3127d 100644 --- a/services/content/src/textbooks/textbooks.controller.ts +++ b/services/content/src/textbooks/textbooks.controller.ts @@ -1,25 +1,59 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; -import { TextbooksService } from './textbooks.service.js'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from "@nestjs/common"; +import { + TextbooksService, + type CreateTextbookInput, + type UpdateTextbookInput, +} from "./textbooks.service.js"; +import type { Textbook } from "./textbooks.schema.js"; -@Controller('textbooks') +@Controller("textbooks") export class TextbooksController { constructor(private readonly service: TextbooksService) {} @Post() - async create(@Body() body: unknown) { - const result = await this.service.create(body as any); + async create( + @Body() body: CreateTextbookInput, + ): Promise<{ success: true; data: { id: string } }> { + const result = await this.service.create(body); return { success: true, data: result }; } @Get() - async list() { - const result = await this.service.list(); - return { success: true, data: result }; + async list(): Promise<{ success: true; data: Textbook[] }> { + const data = await this.service.list(); + return { success: true, data }; } - @Get(':id') - async getById(@Param('id') id: string) { - const result = await this.service.getById(id); - return { success: true, data: result }; + @Get(":id") + async getById( + @Param("id") id: string, + ): Promise<{ success: true; data: Textbook }> { + const data = await this.service.getById(id); + return { success: true, data }; + } + + @Put(":id") + async update( + @Param("id") id: string, + @Body() body: UpdateTextbookInput, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.update(id, body); + return { success: true, data: { success: true } }; + } + + @Delete(":id") + async remove( + @Param("id") id: string, + ): Promise<{ success: true; data: { success: true } }> { + await this.service.delete(id); + return { success: true, data: { success: true } }; } } diff --git a/services/content/src/textbooks/textbooks.module.ts b/services/content/src/textbooks/textbooks.module.ts index 1c15576..14aed66 100644 --- a/services/content/src/textbooks/textbooks.module.ts +++ b/services/content/src/textbooks/textbooks.module.ts @@ -1,9 +1,10 @@ -import { Module } from '@nestjs/common'; -import { TextbooksController } from './textbooks.controller.js'; -import { TextbooksService } from './textbooks.service.js'; +import { Module } from "@nestjs/common"; +import { TextbooksController } from "./textbooks.controller.js"; +import { TextbooksService } from "./textbooks.service.js"; @Module({ controllers: [TextbooksController], providers: [TextbooksService], + exports: [TextbooksService], }) export class TextbooksModule {} diff --git a/services/content/src/textbooks/textbooks.schema.ts b/services/content/src/textbooks/textbooks.schema.ts index 57e07be..e22cb24 100644 --- a/services/content/src/textbooks/textbooks.schema.ts +++ b/services/content/src/textbooks/textbooks.schema.ts @@ -1,30 +1,40 @@ -import { mysqlTable, varchar, char, timestamp, text, integer } from 'drizzle-orm/mysql-core'; +import { + mysqlTable, + varchar, + char, + timestamp, + text, + int, +} from "drizzle-orm/mysql-core"; -export const textbooks = mysqlTable('content_textbooks', { - id: char('id', { length: 36 }).notNull().primaryKey(), - title: varchar('title', { length: 200 }).notNull(), - subjectId: char('subject_id', { length: 36 }).notNull(), - gradeId: char('grade_id', { length: 36 }).notNull(), - version: varchar('version', { length: 50 }).notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), +export const textbooks = mysqlTable("content_textbooks", { + id: char("id", { length: 36 }).notNull().primaryKey(), + title: varchar("title", { length: 200 }).notNull(), + subjectId: char("subject_id", { length: 36 }).notNull(), + gradeId: char("grade_id", { length: 36 }).notNull(), + version: varchar("version", { length: 50 }).notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); -export const chapters = mysqlTable('content_chapters', { - id: char('id', { length: 36 }).notNull().primaryKey(), - textbookId: char('textbook_id', { length: 36 }).notNull(), - title: varchar('title', { length: 200 }).notNull(), - order: integer('order_num').notNull(), - parentId: char('parent_id', { length: 36 }), +export const chapters = mysqlTable("content_chapters", { + id: char("id", { length: 36 }).notNull().primaryKey(), + textbookId: char("textbook_id", { length: 36 }).notNull(), + title: varchar("title", { length: 200 }).notNull(), + order: int("order_num").notNull(), + parentId: char("parent_id", { length: 36 }), }); -export const knowledgePoints = mysqlTable('content_knowledge_points', { - id: char('id', { length: 36 }).notNull().primaryKey(), - chapterId: char('chapter_id', { length: 36 }).notNull(), - title: varchar('title', { length: 200 }).notNull(), - description: text('description'), +export const knowledgePoints = mysqlTable("content_knowledge_points", { + id: char("id", { length: 36 }).notNull().primaryKey(), + chapterId: char("chapter_id", { length: 36 }).notNull(), + title: varchar("title", { length: 200 }).notNull(), + description: text("description"), }); export type Textbook = typeof textbooks.$inferSelect; +export type NewTextbook = typeof textbooks.$inferInsert; export type Chapter = typeof chapters.$inferSelect; +export type NewChapter = typeof chapters.$inferInsert; export type KnowledgePoint = typeof knowledgePoints.$inferSelect; +export type NewKnowledgePoint = typeof knowledgePoints.$inferInsert; diff --git a/services/content/src/textbooks/textbooks.service.ts b/services/content/src/textbooks/textbooks.service.ts index 54090a4..6dfdc17 100644 --- a/services/content/src/textbooks/textbooks.service.ts +++ b/services/content/src/textbooks/textbooks.service.ts @@ -1,69 +1,70 @@ -import { Injectable } from '@nestjs/common'; -import { getDb } from '../config/database.js'; -import { neo4jDriver } from '../config/neo4j.js'; -import { textbooks, chapters, knowledgePoints } from './textbooks.schema.js'; -import { v4 as uuidv4 } from 'uuid'; -import { eq } from 'drizzle-orm'; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { Injectable } from "@nestjs/common"; +import { db } from "../config/database.js"; +import { textbooks, type Textbook } from "./textbooks.schema.js"; +import { + NotFoundError, + ValidationError, +} from "../shared/errors/application-error.js"; + +export interface CreateTextbookInput { + title: string; + subjectId: string; + gradeId: string; + version: string; +} + +export interface UpdateTextbookInput { + title?: string; + subjectId?: string; + gradeId?: string; + version?: string; +} @Injectable() export class TextbooksService { - async create(data: { title: string; subjectId: string; gradeId: string; version: string }) { - const id = uuidv4(); - const db = getDb(); - await db.insert(textbooks).values({ id, ...data }); - return { id, ...data }; + async create(input: CreateTextbookInput): Promise<{ id: string }> { + if (!input.title || !input.subjectId || !input.gradeId || !input.version) { + throw new ValidationError( + "title, subjectId, gradeId, version are required", + ); + } + + const id = randomUUID(); + await db.insert(textbooks).values({ + id, + title: input.title, + subjectId: input.subjectId, + gradeId: input.gradeId, + version: input.version, + }); + return { id }; } - async list() { - const db = getDb(); + async list(): Promise { return db.select().from(textbooks); } - async getById(id: string) { - const db = getDb(); - const [result] = await db.select().from(textbooks).where(eq(textbooks.id, id)); + async getById(id: string): Promise { + const [result] = await db + .select() + .from(textbooks) + .where(eq(textbooks.id, id)) + .limit(1); + if (!result) { + throw new NotFoundError("Textbook", id); + } return result; } - // 知识图谱:在 Neo4j 中创建知识点节点和关系 - async createKnowledgeGraph(knowledgePointId: string, title: string, prerequisiteIds: string[]): Promise { - const session = neo4jDriver.session(); - try { - await session.executeWrite((tx) => - tx.run( - 'MERGE (kp:KnowledgePoint {id: $id, title: $title})', - { id: knowledgePointId, title } - ) - ); - - for (const prereqId of prerequisiteIds) { - await session.executeWrite((tx) => - tx.run( - `MATCH (prereq:KnowledgePoint {id: $prereqId}), (kp:KnowledgePoint {id: $kpId}) - MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`, - { prereqId, kpId: knowledgePointId } - ) - ); - } - } finally { - await session.close(); - } + async update(id: string, data: UpdateTextbookInput): Promise { + await this.getById(id); + await db.update(textbooks).set(data).where(eq(textbooks.id, id)); } - // 查询知识图谱(前置知识点) - async getPrerequisites(knowledgePointId: string): Promise { - const session = neo4jDriver.session(); - 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) => ({ id: r.get('id'), title: r.get('title') })); - } finally { - await session.close(); - } + async delete(id: string): Promise { + await this.getById(id); + await db.delete(textbooks).where(eq(textbooks.id, id)); } }