import { createId } from "@paralleldrive/cuid2"; import { Injectable } from "@nestjs/common"; import { textbooksRepository } from "./textbooks.repository.js"; import type { Textbook, NewTextbook } from "./textbooks.schema.js"; import { OutboxService } from "../shared/outbox/outbox.service.js"; import { EagerInvalidationService } from "../shared/cache/eager-invalidation.js"; import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js"; import { ConflictError, NotFoundError, } from "../shared/errors/application-error.js"; export interface CreateTextbookInput { title: string; subjectId: string; gradeId: string; version?: string; metadata?: Record | null; } export interface UpdateTextbookInput { title?: string; status?: string; metadata?: Record | null; } export interface ListTextbooksInput { subjectId?: string; gradeId?: string; page?: number; pageSize?: number; } /** 写操作返回结构,携带 updated_at 作为乐观锁版本(ADR-039)。 */ export interface WriteResult { id: string; updatedAt: Date; } @Injectable() export class TextbooksService { constructor( private readonly outbox: OutboxService, private readonly cacheInvalidation: EagerInvalidationService, ) {} async create(input: CreateTextbookInput): Promise { const id = createId(); const record: NewTextbook = { id, title: input.title, subjectId: input.subjectId, gradeId: input.gradeId, 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, }, ); // ADR-038: MySQL 提交后同步失效 Redis 查询缓存(软失败,不阻断)。 await this.cacheInvalidation.invalidateTextbook(id); // 回查以获取 DB 生成的 updated_at(乐观锁版本)。 const created = await textbooksRepository.findById(id); return { id, updatedAt: created?.updatedAt ?? new Date() }; } async list(query?: ListTextbooksInput): Promise { return textbooksRepository.find(query); } async getById(id: string): Promise { const result = await textbooksRepository.findById(id); if (!result) { throw new NotFoundError("Textbook", id); } return result; } async update( id: string, data: UpdateTextbookInput, expectedVersion?: string, ): Promise { const existing = await this.getById(id); this.assertVersionMatch(existing.updatedAt, expectedVersion); 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, }); await this.cacheInvalidation.invalidateTextbook(id); const updated = await textbooksRepository.findById(id); return { id, updatedAt: updated?.updatedAt ?? new Date() }; } async delete(id: string, expectedVersion?: string): Promise { const existing = await this.getById(id); this.assertVersionMatch(existing.updatedAt, expectedVersion); await textbooksRepository.delete(id); // 教材删除视为归档事件,下游可感知失效 await this.outbox.publish( EVENT_TYPES.TEXTBOOK_ARCHIVED, AGGREGATE_TYPES.TEXTBOOK, id, { deleted: true }, ); await this.cacheInvalidation.invalidateTextbook(id); } // ========== P6.3: 教材版本管理 ========== /** * 归档教材:将 status 置为 archived,发布 TEXTBOOK_ARCHIVED 事件。 * 与 update(id, { status: "archived" }) 的区别: * - 显式语义化方法,便于权限粒度控制和审计 * - 不接受其他字段变更,仅做状态归档 */ async archiveTextbook(id: string): Promise { const existing = await this.getById(id); await textbooksRepository.update(id, { status: "archived" }); await this.outbox.publish( EVENT_TYPES.TEXTBOOK_ARCHIVED, AGGREGATE_TYPES.TEXTBOOK, id, { title: existing.title, status: "archived", previous_status: existing.status, }, ); await this.cacheInvalidation.invalidateTextbook(id); } /** * 列出同 subject + grade 的所有教材版本(含已归档)。 * 用于版本管理界面展示历史版本。 */ async listVersions(subjectId: string, gradeId: string): Promise { return textbooksRepository.findBySubjectAndGrade(subjectId, gradeId); } /** * 乐观锁版本校验(ADR-039):expectedVersion 为客户端携带的 updated_at(ISO)。 * 提供时若与当前记录的 updated_at 不一致,抛出 409 Conflict。 */ private assertVersionMatch( currentUpdatedAt: Date, expectedVersion?: string, ): void { if (!expectedVersion) return; if (currentUpdatedAt.toISOString() !== expectedVersion) { throw new ConflictError( "Textbook version mismatch (optimistic lock conflict)", { expected: expectedVersion, current: currentUpdatedAt.toISOString(), }, ); } } }