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,17 +3,20 @@ import {
Controller,
Delete,
Get,
Headers,
Param,
Post,
Put,
} from "@nestjs/common";
import { ChaptersService } from "./chapters.service.js";
import type { ChapterWriteResult } from "./chapters.service.js";
import type { Chapter } from "./chapters.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import { createChapterSchema, updateChapterSchema } from "./chapters.dto.js";
import { extractExpectedVersion } from "../shared/cache/version-header.js";
@Controller("chapters")
export class ChaptersController {
@@ -23,7 +26,7 @@ export class ChaptersController {
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
async create(
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
): Promise<{ success: true; data: ChapterWriteResult }> {
const input = createChapterSchema.parse(body);
const result = await this.service.createChapter(input);
return { success: true, data: result };
@@ -52,18 +55,24 @@ export class ChaptersController {
async update(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
@Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: ChapterWriteResult }> {
const input = updateChapterSchema.parse(body);
await this.service.updateChapter(id, input);
return { success: true, data: { success: true } };
const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
const result = await this.service.updateChapter(id, input, expectedVersion);
return { success: true, data: result };
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_DELETE)
async remove(
@Param("id") id: string,
@Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.deleteChapter(id);
const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
await this.service.deleteChapter(id, expectedVersion);
return { success: true, data: { success: true } };
}
}