chore(content): merge content full implementation into main

Merge feat/content-ai09 with complete content service
This commit is contained in:
SpecialX
2026-07-10 19:13:02 +08:00
74 changed files with 5256 additions and 355 deletions

View File

@@ -1,57 +1,71 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
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";
import { textbooksRepository } from "./textbooks.repository.js";
import type { Textbook, NewTextbook } from "./textbooks.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.js";
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
import { NotFoundError } from "../shared/errors/application-error.js";
export interface CreateTextbookInput {
title: string;
subjectId: string;
gradeId: string;
version: string;
version?: string;
metadata?: Record<string, unknown> | null;
}
export interface UpdateTextbookInput {
title?: string;
status?: string;
metadata?: Record<string, unknown> | null;
}
export interface ListTextbooksInput {
subjectId?: string;
gradeId?: string;
version?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class TextbooksService {
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",
);
}
constructor(private readonly outbox: OutboxService) {}
const id = randomUUID();
await db.insert(textbooks).values({
async create(input: CreateTextbookInput): Promise<{ id: string }> {
const id = createId();
const record: NewTextbook = {
id,
title: input.title,
subjectId: input.subjectId,
gradeId: input.gradeId,
version: input.version,
});
version: input.version ?? "1.0",
status: "draft",
metadata: input.metadata ?? null,
};
await textbooksRepository.create(record);
await this.outbox.publish(
EVENT_TYPES.TEXTBOOK_CREATED,
AGGREGATE_TYPES.TEXTBOOK,
id,
{
title: record.title,
subject_id: record.subjectId,
grade_id: record.gradeId,
version: record.version,
status: record.status,
},
);
return { id };
}
async list(): Promise<Textbook[]> {
return db.select().from(textbooks);
async list(query?: ListTextbooksInput): Promise<Textbook[]> {
return textbooksRepository.find(query);
}
async getById(id: string): Promise<Textbook> {
const [result] = await db
.select()
.from(textbooks)
.where(eq(textbooks.id, id))
.limit(1);
const result = await textbooksRepository.findById(id);
if (!result) {
throw new NotFoundError("Textbook", id);
}
@@ -59,12 +73,32 @@ export class TextbooksService {
}
async update(id: string, data: UpdateTextbookInput): Promise<void> {
await this.getById(id);
await db.update(textbooks).set(data).where(eq(textbooks.id, id));
const existing = await this.getById(id);
await textbooksRepository.update(id, data);
const eventType =
data.status === "published"
? EVENT_TYPES.TEXTBOOK_PUBLISHED
: data.status === "archived"
? EVENT_TYPES.TEXTBOOK_ARCHIVED
: EVENT_TYPES.TEXTBOOK_UPDATED;
await this.outbox.publish(eventType, AGGREGATE_TYPES.TEXTBOOK, id, {
title: data.title ?? existing.title,
status: data.status ?? existing.status,
metadata: data.metadata ?? existing.metadata,
});
}
async delete(id: string): Promise<void> {
await this.getById(id);
await db.delete(textbooks).where(eq(textbooks.id, id));
await textbooksRepository.delete(id);
// 教材删除视为归档事件,下游可感知失效
await this.outbox.publish(
EVENT_TYPES.TEXTBOOK_ARCHIVED,
AGGREGATE_TYPES.TEXTBOOK,
id,
{ deleted: true },
);
}
}