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:
47
scripts/content-init.sql
Normal file
47
scripts/content-init.sql
Normal 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;
|
||||
@@ -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 {}
|
||||
|
||||
61
services/content/src/chapters/chapters.controller.ts
Normal file
61
services/content/src/chapters/chapters.controller.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
10
services/content/src/chapters/chapters.module.ts
Normal file
10
services/content/src/chapters/chapters.module.ts
Normal 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 {}
|
||||
35
services/content/src/chapters/chapters.repository.ts
Normal file
35
services/content/src/chapters/chapters.repository.ts
Normal 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();
|
||||
5
services/content/src/chapters/chapters.schema.ts
Normal file
5
services/content/src/chapters/chapters.schema.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
chapters,
|
||||
type Chapter,
|
||||
type NewChapter,
|
||||
} from "../textbooks/textbooks.schema.js";
|
||||
62
services/content/src/chapters/chapters.service.ts
Normal file
62
services/content/src/chapters/chapters.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
// 连接超时 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<void> {
|
||||
await neo4jDriver.close();
|
||||
if (driver) {
|
||||
await driver.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
knowledgePoints,
|
||||
type KnowledgePoint,
|
||||
type NewKnowledgePoint,
|
||||
} from "../textbooks/textbooks.schema.js";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
61
services/content/src/questions/questions.controller.ts
Normal file
61
services/content/src/questions/questions.controller.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
10
services/content/src/questions/questions.module.ts
Normal file
10
services/content/src/questions/questions.module.ts
Normal 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 {}
|
||||
39
services/content/src/questions/questions.repository.ts
Normal file
39
services/content/src/questions/questions.repository.ts
Normal 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();
|
||||
23
services/content/src/questions/questions.schema.ts
Normal file
23
services/content/src/questions/questions.schema.ts
Normal 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;
|
||||
83
services/content/src/questions/questions.service.ts
Normal file
83
services/content/src/questions/questions.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user