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
This commit is contained in:
SpecialX
2026-07-15 01:28:20 +08:00
parent 47a062606f
commit a75527be80
26 changed files with 673 additions and 72 deletions

View File

@@ -3,8 +3,12 @@ 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 { NotFoundError } from "../shared/errors/application-error.js";
import {
ConflictError,
NotFoundError,
} from "../shared/errors/application-error.js";
export interface CreateTextbookInput {
title: string;
@@ -27,11 +31,20 @@ export interface ListTextbooksInput {
pageSize?: number;
}
/** 写操作返回结构,携带 updated_at 作为乐观锁版本ADR-039。 */
export interface WriteResult {
id: string;
updatedAt: Date;
}
@Injectable()
export class TextbooksService {
constructor(private readonly outbox: OutboxService) {}
constructor(
private readonly outbox: OutboxService,
private readonly cacheInvalidation: EagerInvalidationService,
) {}
async create(input: CreateTextbookInput): Promise<{ id: string }> {
async create(input: CreateTextbookInput): Promise<WriteResult> {
const id = createId();
const record: NewTextbook = {
id,
@@ -57,7 +70,12 @@ export class TextbooksService {
},
);
return { id };
// 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[]> {
@@ -72,8 +90,14 @@ export class TextbooksService {
return result;
}
async update(id: string, data: UpdateTextbookInput): Promise<void> {
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 =
@@ -88,10 +112,17 @@ export class TextbooksService {
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): Promise<void> {
await this.getById(id);
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(
@@ -100,6 +131,8 @@ export class TextbooksService {
id,
{ deleted: true },
);
await this.cacheInvalidation.invalidateTextbook(id);
}
// ========== P6.3: 教材版本管理 ==========
@@ -124,6 +157,8 @@ export class TextbooksService {
previous_status: existing.status,
},
);
await this.cacheInvalidation.invalidateTextbook(id);
}
/**
@@ -133,4 +168,24 @@ export class TextbooksService {
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(),
},
);
}
}
}