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

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