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

47
scripts/content-init.sql Normal file
View File

@@ -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;

View File

@@ -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 {}

View File

@@ -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 } };
}
}

View File

@@ -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 {}

View File

@@ -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<Chapter | undefined> {
const [result] = await db
.select()
.from(chapters)
.where(eq(chapters.id, id))
.limit(1);
return result;
}
async findByTextbookId(textbookId: string): Promise<Chapter[]> {
return db
.select()
.from(chapters)
.where(eq(chapters.textbookId, textbookId));
}
async create(data: NewChapter): Promise<void> {
await db.insert(chapters).values(data);
}
async update(id: string, data: Partial<NewChapter>): Promise<void> {
await db.update(chapters).set(data).where(eq(chapters.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(chapters).where(eq(chapters.id, id));
}
}
export const chaptersRepository = new ChaptersRepository();

View File

@@ -0,0 +1,5 @@
export {
chapters,
type Chapter,
type NewChapter,
} from "../textbooks/textbooks.schema.js";

View File

@@ -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<Chapter> {
const chapter = await chaptersRepository.findById(id);
if (!chapter) {
throw new NotFoundError("Chapter", id);
}
return chapter;
}
async listChaptersByTextbook(textbookId: string): Promise<Chapter[]> {
return chaptersRepository.findByTextbookId(textbookId);
}
async updateChapter(id: string, data: UpdateChapterInput): Promise<void> {
await this.getChapter(id);
await chaptersRepository.update(id, data);
}
async deleteChapter(id: string): Promise<void> {
await this.getChapter(id);
await chaptersRepository.delete(id);
}
}

View File

@@ -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;
export function getDb() {
if (!pool) {
pool = mysql.createPool({
const 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<void> {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -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<typeof envSchema>;
@@ -19,8 +24,11 @@ export type Env = z.infer<typeof envSchema>;
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;
}

View File

@@ -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(
// 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)
);
neo4j.auth.basic("neo4j", env.NEO4J_PASSWORD),
// 连接超时 3sNeo4j 不可用时快速失败,避免拖慢 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<void> {
await neo4jDriver.close();
if (driver) {
await driver.close();
}
}

View File

@@ -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 } };
}
}

View File

@@ -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 {}

View File

@@ -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<KnowledgePoint | undefined> {
const [result] = await db
.select()
.from(knowledgePoints)
.where(eq(knowledgePoints.id, id))
.limit(1);
return result;
}
async findByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
return db
.select()
.from(knowledgePoints)
.where(eq(knowledgePoints.chapterId, chapterId));
}
async create(data: NewKnowledgePoint): Promise<void> {
await db.insert(knowledgePoints).values(data);
}
async update(id: string, data: Partial<NewKnowledgePoint>): Promise<void> {
await db
.update(knowledgePoints)
.set(data)
.where(eq(knowledgePoints.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id));
}
}
export const knowledgePointsRepository = new KnowledgePointsRepository();

View File

@@ -0,0 +1,5 @@
export {
knowledgePoints,
type KnowledgePoint,
type NewKnowledgePoint,
} from "../textbooks/textbooks.schema.js";

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();
}
}
}

View File

@@ -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<void> {
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);
});

View File

@@ -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 } };
}
}

View File

@@ -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 {}

View File

@@ -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<Question | undefined> {
const [result] = await db
.select()
.from(questions)
.where(eq(questions.id, id))
.limit(1);
return result;
}
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
return db
.select()
.from(questions)
.where(eq(questions.knowledgePointId, knowledgePointId));
}
async create(data: NewQuestion): Promise<void> {
await db.insert(questions).values(data);
}
async update(id: string, data: Partial<NewQuestion>): Promise<void> {
await db.update(questions).set(data).where(eq(questions.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(questions).where(eq(questions.id, id));
}
}
export const questionsRepository = new QuestionsRepository();

View File

@@ -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;

View File

@@ -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<Question> {
const question = await questionsRepository.findById(id);
if (!question) {
throw new NotFoundError("Question", id);
}
return question;
}
async listByKnowledgePoint(knowledgePointId: string): Promise<Question[]> {
return questionsRepository.findByKnowledgePointId(knowledgePointId);
}
async updateQuestion(id: string, data: UpdateQuestionInput): Promise<void> {
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<void> {
await this.getQuestion(id);
await questionsRepository.delete(id);
}
}

View File

@@ -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<Response>();
const request = ctx.getRequest<Request>();
// 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<string, unknown>;
@@ -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;
}

View File

@@ -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 /healthzliveness仅返回进程存活不检查依赖。
* - GET /readyzreadiness检查 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,
);

View File

@@ -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],
})

View File

@@ -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 / SIGINTNestJS 会依次调用 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<void> {
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<unknown>): Promise<void> {
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)}`,
);
}
}

View File

@@ -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 } };
}
}

View File

@@ -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 {}

View File

@@ -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;

View File

@@ -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",
);
}
async list() {
const db = getDb();
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(): Promise<Textbook[]> {
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<Textbook> {
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<void> {
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<void> {
await this.getById(id);
await db.update(textbooks).set(data).where(eq(textbooks.id, id));
}
// 查询知识图谱(前置知识点)
async getPrerequisites(knowledgePointId: string): Promise<unknown[]> {
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<void> {
await this.getById(id);
await db.delete(textbooks).where(eq(textbooks.id, id));
}
}