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
192 lines
5.6 KiB
TypeScript
192 lines
5.6 KiB
TypeScript
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-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(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|