Files
Edu/services/content/src/textbooks/textbooks.service.ts
SpecialX a75527be80 feat(content): eager invalidation and optimistic lock for cqrs
M4: ADR-038 Eager Invalidation + ADR-039 Optimistic Lock

- EagerInvalidationService: Redis DEL after MySQL commit

- Cache key conventions for textbook/chapter/kp/question

- Version header check (If-Match/X-Expected-Version) on write endpoints

- 409 Conflict on version mismatch

- All write endpoints return updatedAt timestamp
2026-07-15 01:28:20 +08:00

192 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> | null;
}
export interface UpdateTextbookInput {
title?: string;
status?: string;
metadata?: Record<string, unknown> | 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<WriteResult> {
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<Textbook[]> {
return textbooksRepository.find(query);
}
async getById(id: string): Promise<Textbook> {
const result = await textbooksRepository.findById(id);
if (!result) {
throw new NotFoundError("Textbook", id);
}
return result;
}
async update(
id: string,
data: UpdateTextbookInput,
expectedVersion?: string,
): Promise<WriteResult> {
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<void> {
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<void> {
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<Textbook[]> {
return textbooksRepository.findBySubjectAndGrade(subjectId, gradeId);
}
/**
* 乐观锁版本校验ADR-039expectedVersion 为客户端携带的 updated_atISO
* 提供时若与当前记录的 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(),
},
);
}
}
}