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

@@ -31,6 +31,7 @@
"dataloader": "^2.2.2", "dataloader": "^2.2.2",
"drizzle-orm": "^0.31.0", "drizzle-orm": "^0.31.0",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"ioredis": "^5.4.0",
"kafkajs": "^2.2.4", "kafkajs": "^2.2.4",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"neo4j-driver": "^5.23.0", "neo4j-driver": "^5.23.0",

View File

@@ -65,11 +65,18 @@ describe("ChaptersController", () => {
describe("update", () => { describe("update", () => {
it("should parse input and call service.updateChapter", async () => { it("should parse input and call service.updateChapter", async () => {
const updatedAt = new Date();
mockService.updateChapter.mockResolvedValue({ id: "ch-1", updatedAt });
const result = await controller.update("ch-1", { title: "New" }); const result = await controller.update("ch-1", { title: "New" });
expect(mockService.updateChapter).toHaveBeenCalledWith("ch-1", { expect(mockService.updateChapter).toHaveBeenCalledWith(
title: "New", "ch-1",
{ title: "New" },
undefined,
);
expect(result).toEqual({
success: true,
data: { id: "ch-1", updatedAt },
}); });
expect(result).toEqual({ success: true, data: { success: true } });
}); });
it("should throw on invalid status", async () => { it("should throw on invalid status", async () => {
@@ -82,7 +89,7 @@ describe("ChaptersController", () => {
describe("remove", () => { describe("remove", () => {
it("should call service.deleteChapter", async () => { it("should call service.deleteChapter", async () => {
const result = await controller.remove("ch-1"); const result = await controller.remove("ch-1");
expect(mockService.deleteChapter).toHaveBeenCalledWith("ch-1"); expect(mockService.deleteChapter).toHaveBeenCalledWith("ch-1", undefined);
expect(result).toEqual({ success: true, data: { success: true } }); expect(result).toEqual({ success: true, data: { success: true } });
}); });
}); });

View File

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

View File

@@ -3,9 +3,10 @@ import { ChaptersController } from "./chapters.controller.js";
import { ChaptersService } from "./chapters.service.js"; import { ChaptersService } from "./chapters.service.js";
import { ChaptersRepository } from "./chapters.repository.js"; import { ChaptersRepository } from "./chapters.repository.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js";
import { CacheModule } from "../shared/cache/cache.module.js";
@Module({ @Module({
imports: [OutboxModule], imports: [OutboxModule, CacheModule],
controllers: [ChaptersController], controllers: [ChaptersController],
providers: [ChaptersService, ChaptersRepository], providers: [ChaptersService, ChaptersRepository],
exports: [ChaptersService, ChaptersRepository], exports: [ChaptersService, ChaptersRepository],

View File

@@ -18,12 +18,24 @@ const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"), publish: vi.fn().mockResolvedValue("event-id"),
}; };
const mockCacheInvalidation = {
invalidateTextbook: vi.fn().mockResolvedValue(undefined),
invalidateChapter: vi.fn().mockResolvedValue(undefined),
invalidateKnowledgePoint: vi.fn().mockResolvedValue(undefined),
invalidateQuestion: vi.fn().mockResolvedValue(undefined),
invalidateKeys: vi.fn().mockResolvedValue(undefined),
invalidatePattern: vi.fn().mockResolvedValue(undefined),
};
describe("ChaptersService", () => { describe("ChaptersService", () => {
let service: ChaptersService; let service: ChaptersService;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
service = new ChaptersService(mockOutbox as never); service = new ChaptersService(
mockOutbox as never,
mockCacheInvalidation as never,
);
}); });
describe("createChapter", () => { describe("createChapter", () => {
@@ -36,6 +48,7 @@ describe("ChaptersService", () => {
const result = await service.createChapter(input); const result = await service.createChapter(input);
expect(result.id).toBeDefined(); expect(result.id).toBeDefined();
expect(result.updatedAt).toBeInstanceOf(Date);
expect(chaptersRepository.create).toHaveBeenCalledWith( expect(chaptersRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
textbookId: "tb-1", textbookId: "tb-1",

View File

@@ -3,8 +3,12 @@ import { Injectable } from "@nestjs/common";
import { chaptersRepository } from "./chapters.repository.js"; import { chaptersRepository } from "./chapters.repository.js";
import type { Chapter, NewChapter } from "./chapters.schema.js"; import type { Chapter, NewChapter } from "./chapters.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.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 { 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 CreateChapterInput { export interface CreateChapterInput {
textbookId: string; textbookId: string;
@@ -19,11 +23,20 @@ export interface UpdateChapterInput {
status?: string; status?: string;
} }
/** 写操作返回结构,携带 updated_at 作为乐观锁版本ADR-039。 */
export interface ChapterWriteResult {
id: string;
updatedAt: Date;
}
@Injectable() @Injectable()
export class ChaptersService { export class ChaptersService {
constructor(private readonly outbox: OutboxService) {} constructor(
private readonly outbox: OutboxService,
private readonly cacheInvalidation: EagerInvalidationService,
) {}
async createChapter(input: CreateChapterInput): Promise<{ id: string }> { async createChapter(input: CreateChapterInput): Promise<ChapterWriteResult> {
const id = createId(); const id = createId();
const record: NewChapter = { const record: NewChapter = {
id, id,
@@ -47,7 +60,12 @@ export class ChaptersService {
}, },
); );
return { id }; // ADR-038: MySQL 提交后同步失效 Redis 查询缓存(软失败,不阻断)。
await this.cacheInvalidation.invalidateChapter(id, input.textbookId);
// 回查以获取 DB 生成的 updated_at乐观锁版本
const created = await chaptersRepository.findById(id);
return { id, updatedAt: created?.updatedAt ?? new Date() };
} }
async getChapter(id: string): Promise<Chapter> { async getChapter(id: string): Promise<Chapter> {
@@ -62,8 +80,14 @@ export class ChaptersService {
return chaptersRepository.findByTextbookId(textbookId); return chaptersRepository.findByTextbookId(textbookId);
} }
async updateChapter(id: string, data: UpdateChapterInput): Promise<void> { async updateChapter(
id: string,
data: UpdateChapterInput,
expectedVersion?: string,
): Promise<ChapterWriteResult> {
const existing = await this.getChapter(id); const existing = await this.getChapter(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await chaptersRepository.update(id, data); await chaptersRepository.update(id, data);
await this.outbox.publish( await this.outbox.publish(
@@ -76,10 +100,17 @@ export class ChaptersService {
status: data.status ?? existing.status, status: data.status ?? existing.status,
}, },
); );
await this.cacheInvalidation.invalidateChapter(id, existing.textbookId);
const updated = await chaptersRepository.findById(id);
return { id, updatedAt: updated?.updatedAt ?? new Date() };
} }
async deleteChapter(id: string): Promise<void> { async deleteChapter(id: string, expectedVersion?: string): Promise<void> {
await this.getChapter(id); const existing = await this.getChapter(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await chaptersRepository.delete(id); await chaptersRepository.delete(id);
await this.outbox.publish( await this.outbox.publish(
@@ -88,5 +119,27 @@ export class ChaptersService {
id, id,
{ deleted: true }, { deleted: true },
); );
await this.cacheInvalidation.invalidateChapter(id, existing.textbookId);
}
/**
* 乐观锁版本校验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(
"Chapter version mismatch (optimistic lock conflict)",
{
expected: expectedVersion,
current: currentUpdatedAt.toISOString(),
},
);
}
} }
} }

View File

@@ -0,0 +1,44 @@
import { Redis } from "ioredis";
import type { Redis as RedisClient } from "ioredis";
import { env } from "./env.js";
// Redis Client 惰性初始化:未配置 REDIS_URL 时 client 保持 null
// 服务仍可正常启动。所有依赖 Redis 的功能Eager Invalidation
// client 为 null 时优雅降级(跳过失效,由 Kafka projector 兜底),
// 不会阻塞主流程。
let client: RedisClient | null = null;
let initAttempted = false;
export function getRedis(): RedisClient | null {
if (initAttempted) {
return client;
}
initAttempted = true;
if (!env.REDIS_URL) {
return null;
}
try {
client = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 1,
enableReadyCheck: true,
lazyConnect: false,
});
} catch (err) {
console.warn(
"Redis client init failed, running without Redis:",
err instanceof Error ? err.message : String(err),
);
client = null;
}
return client;
}
/**
* 关闭 Redis Client 连接。
*/
export async function closeRedis(): Promise<void> {
if (client) {
await client.quit();
client = null;
}
}

View File

@@ -108,18 +108,31 @@ describe("KnowledgePointsController", () => {
describe("update", () => { describe("update", () => {
it("should parse input and call service.updateKnowledgePoint", async () => { it("should parse input and call service.updateKnowledgePoint", async () => {
const result = await controller.update("kp-1", { title: "New" }); const updatedAt = new Date();
expect(mockService.updateKnowledgePoint).toHaveBeenCalledWith("kp-1", { mockService.updateKnowledgePoint.mockResolvedValue({
title: "New", id: "kp-1",
updatedAt,
});
const result = await controller.update("kp-1", { title: "New" });
expect(mockService.updateKnowledgePoint).toHaveBeenCalledWith(
"kp-1",
{ title: "New" },
undefined,
);
expect(result).toEqual({
success: true,
data: { id: "kp-1", updatedAt },
}); });
expect(result).toEqual({ success: true, data: { success: true } });
}); });
}); });
describe("remove", () => { describe("remove", () => {
it("should call service.deleteKnowledgePoint", async () => { it("should call service.deleteKnowledgePoint", async () => {
const result = await controller.remove("kp-1"); const result = await controller.remove("kp-1");
expect(mockService.deleteKnowledgePoint).toHaveBeenCalledWith("kp-1"); expect(mockService.deleteKnowledgePoint).toHaveBeenCalledWith(
"kp-1",
undefined,
);
expect(result).toEqual({ success: true, data: { success: true } }); expect(result).toEqual({ success: true, data: { success: true } });
}); });
}); });

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
Param, Param,
Post, Post,
Put, Put,
@@ -12,6 +13,7 @@ import {
KnowledgePointsService, KnowledgePointsService,
type PrerequisiteNode, type PrerequisiteNode,
type VisualizationResult, type VisualizationResult,
type KnowledgePointWriteResult,
} from "./knowledge-points.service.js"; } from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js"; import type { KnowledgePoint } from "./knowledge-points.schema.js";
import { import {
@@ -23,6 +25,7 @@ import {
updateKnowledgePointSchema, updateKnowledgePointSchema,
addPrerequisiteSchema, addPrerequisiteSchema,
} from "./knowledge-points.dto.js"; } from "./knowledge-points.dto.js";
import { extractExpectedVersion } from "../shared/cache/version-header.js";
@Controller("knowledge-points") @Controller("knowledge-points")
export class KnowledgePointsController { export class KnowledgePointsController {
@@ -32,7 +35,7 @@ export class KnowledgePointsController {
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE) @RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
async create( async create(
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: KnowledgePointWriteResult }> {
const input = createKnowledgePointSchema.parse(body); const input = createKnowledgePointSchema.parse(body);
const result = await this.service.createKnowledgePoint(input); const result = await this.service.createKnowledgePoint(input);
return { success: true, data: result }; return { success: true, data: result };
@@ -91,18 +94,28 @@ export class KnowledgePointsController {
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> { @Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: KnowledgePointWriteResult }> {
const input = updateKnowledgePointSchema.parse(body); const input = updateKnowledgePointSchema.parse(body);
await this.service.updateKnowledgePoint(id, input); const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
return { success: true, data: { success: true } }; const result = await this.service.updateKnowledgePoint(
id,
input,
expectedVersion,
);
return { success: true, data: result };
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE) @RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
@Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {
await this.service.deleteKnowledgePoint(id); const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
await this.service.deleteKnowledgePoint(id, expectedVersion);
return { success: true, data: { success: true } }; return { success: true, data: { success: true } };
} }
} }

View File

@@ -6,9 +6,10 @@ import {
import { KnowledgePointsService } from "./knowledge-points.service.js"; import { KnowledgePointsService } from "./knowledge-points.service.js";
import { KnowledgePointsRepository } from "./knowledge-points.repository.js"; import { KnowledgePointsRepository } from "./knowledge-points.repository.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js";
import { CacheModule } from "../shared/cache/cache.module.js";
@Module({ @Module({
imports: [OutboxModule], imports: [OutboxModule, CacheModule],
controllers: [KnowledgePointsController, KnowledgeGraphController], controllers: [KnowledgePointsController, KnowledgeGraphController],
providers: [KnowledgePointsService, KnowledgePointsRepository], providers: [KnowledgePointsService, KnowledgePointsRepository],
exports: [KnowledgePointsService, KnowledgePointsRepository], exports: [KnowledgePointsService, KnowledgePointsRepository],

View File

@@ -26,12 +26,24 @@ const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"), publish: vi.fn().mockResolvedValue("event-id"),
}; };
const mockCacheInvalidation = {
invalidateTextbook: vi.fn().mockResolvedValue(undefined),
invalidateChapter: vi.fn().mockResolvedValue(undefined),
invalidateKnowledgePoint: vi.fn().mockResolvedValue(undefined),
invalidateQuestion: vi.fn().mockResolvedValue(undefined),
invalidateKeys: vi.fn().mockResolvedValue(undefined),
invalidatePattern: vi.fn().mockResolvedValue(undefined),
};
describe("KnowledgePointsService", () => { describe("KnowledgePointsService", () => {
let service: KnowledgePointsService; let service: KnowledgePointsService;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
service = new KnowledgePointsService(mockOutbox as never); service = new KnowledgePointsService(
mockOutbox as never,
mockCacheInvalidation as never,
);
}); });
describe("createKnowledgePoint", () => { describe("createKnowledgePoint", () => {
@@ -45,6 +57,7 @@ describe("KnowledgePointsService", () => {
const result = await service.createKnowledgePoint(input); const result = await service.createKnowledgePoint(input);
expect(result.id).toBeDefined(); expect(result.id).toBeDefined();
expect(result.updatedAt).toBeInstanceOf(Date);
expect(knowledgePointsRepository.create).toHaveBeenCalledWith( expect(knowledgePointsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
chapterId: "ch-1", chapterId: "ch-1",

View File

@@ -6,9 +6,11 @@ import type {
NewKnowledgePoint, NewKnowledgePoint,
} from "./knowledge-points.schema.js"; } from "./knowledge-points.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.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 { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
import { getNeo4jSession } from "../config/neo4j.js"; import { getNeo4jSession } from "../config/neo4j.js";
import { import {
ConflictError,
NotFoundError, NotFoundError,
ValidationError, ValidationError,
} from "../shared/errors/application-error.js"; } from "../shared/errors/application-error.js";
@@ -51,15 +53,24 @@ export interface VisualizationResult {
edges: VisualizationEdge[]; edges: VisualizationEdge[];
} }
/** 写操作返回结构,携带 updated_at 作为乐观锁版本ADR-039。 */
export interface KnowledgePointWriteResult {
id: string;
updatedAt: Date;
}
@Injectable() @Injectable()
export class KnowledgePointsService { export class KnowledgePointsService {
private readonly logger = new Logger(KnowledgePointsService.name); private readonly logger = new Logger(KnowledgePointsService.name);
constructor(private readonly outbox: OutboxService) {} constructor(
private readonly outbox: OutboxService,
private readonly cacheInvalidation: EagerInvalidationService,
) {}
async createKnowledgePoint( async createKnowledgePoint(
input: CreateKnowledgePointInput, input: CreateKnowledgePointInput,
): Promise<{ id: string }> { ): Promise<KnowledgePointWriteResult> {
const id = createId(); const id = createId();
const record: NewKnowledgePoint = { const record: NewKnowledgePoint = {
id, id,
@@ -85,7 +96,12 @@ export class KnowledgePointsService {
}, },
); );
return { id }; // ADR-038: MySQL 提交后同步失效 Redis 查询缓存(软失败,不阻断)。
await this.cacheInvalidation.invalidateKnowledgePoint(id, input.chapterId);
// 回查以获取 DB 生成的 updated_at乐观锁版本
const created = await knowledgePointsRepository.findById(id);
return { id, updatedAt: created?.updatedAt ?? new Date() };
} }
async getKnowledgePoint(id: string): Promise<KnowledgePoint> { async getKnowledgePoint(id: string): Promise<KnowledgePoint> {
@@ -120,8 +136,11 @@ export class KnowledgePointsService {
async updateKnowledgePoint( async updateKnowledgePoint(
id: string, id: string,
data: UpdateKnowledgePointInput, data: UpdateKnowledgePointInput,
): Promise<void> { expectedVersion?: string,
): Promise<KnowledgePointWriteResult> {
const existing = await this.getKnowledgePoint(id); const existing = await this.getKnowledgePoint(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await knowledgePointsRepository.update(id, data); await knowledgePointsRepository.update(id, data);
await this.outbox.publish( await this.outbox.publish(
@@ -134,10 +153,23 @@ export class KnowledgePointsService {
difficulty: data.difficulty ?? existing.difficulty, difficulty: data.difficulty ?? existing.difficulty,
}, },
); );
await this.cacheInvalidation.invalidateKnowledgePoint(
id,
existing.chapterId,
);
const updated = await knowledgePointsRepository.findById(id);
return { id, updatedAt: updated?.updatedAt ?? new Date() };
} }
async deleteKnowledgePoint(id: string): Promise<void> { async deleteKnowledgePoint(
await this.getKnowledgePoint(id); id: string,
expectedVersion?: string,
): Promise<void> {
const existing = await this.getKnowledgePoint(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await knowledgePointsRepository.delete(id); await knowledgePointsRepository.delete(id);
// 知识点删除事件也走 kp.updated下游可标记节点为失效 // 知识点删除事件也走 kp.updated下游可标记节点为失效
await this.outbox.publish( await this.outbox.publish(
@@ -146,6 +178,11 @@ export class KnowledgePointsService {
id, id,
{ deleted: true }, { deleted: true },
); );
await this.cacheInvalidation.invalidateKnowledgePoint(
id,
existing.chapterId,
);
} }
/** /**
@@ -366,4 +403,24 @@ export class KnowledgePointsService {
const v = obj[key]; const v = obj[key];
return typeof v === "string" ? v : undefined; return typeof v === "string" ? v : undefined;
} }
/**
* 乐观锁版本校验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(
"KnowledgePoint version mismatch (optimistic lock conflict)",
{
expected: expectedVersion,
current: currentUpdatedAt.toISOString(),
},
);
}
}
} }

View File

@@ -29,7 +29,8 @@ describe("QuestionsController", () => {
describe("create", () => { describe("create", () => {
it("should parse input and call service.createQuestion", async () => { it("should parse input and call service.createQuestion", async () => {
mockService.createQuestion.mockResolvedValue({ id: "q-1" }); const updatedAt = new Date();
mockService.createQuestion.mockResolvedValue({ id: "q-1", updatedAt });
const result = await controller.create({ const result = await controller.create({
knowledgePointId: "kp-1", knowledgePointId: "kp-1",
type: "single_choice", type: "single_choice",
@@ -44,7 +45,10 @@ describe("QuestionsController", () => {
answer: "4", answer: "4",
}), }),
); );
expect(result).toEqual({ success: true, data: { id: "q-1" } }); expect(result).toEqual({
success: true,
data: { id: "q-1", updatedAt },
});
}); });
it("should throw on invalid body", async () => { it("should throw on invalid body", async () => {
@@ -101,11 +105,18 @@ describe("QuestionsController", () => {
describe("update", () => { describe("update", () => {
it("should parse input and call service.updateQuestion", async () => { it("should parse input and call service.updateQuestion", async () => {
const updatedAt = new Date();
mockService.updateQuestion.mockResolvedValue({ id: "q-1", updatedAt });
const result = await controller.update("q-1", { content: "New content" }); const result = await controller.update("q-1", { content: "New content" });
expect(mockService.updateQuestion).toHaveBeenCalledWith("q-1", { expect(mockService.updateQuestion).toHaveBeenCalledWith(
content: "New content", "q-1",
{ content: "New content" },
undefined,
);
expect(result).toEqual({
success: true,
data: { id: "q-1", updatedAt },
}); });
expect(result).toEqual({ success: true, data: { success: true } });
}); });
it("should throw on invalid status", async () => { it("should throw on invalid status", async () => {
@@ -118,7 +129,7 @@ describe("QuestionsController", () => {
describe("remove", () => { describe("remove", () => {
it("should call service.deleteQuestion", async () => { it("should call service.deleteQuestion", async () => {
const result = await controller.remove("q-1"); const result = await controller.remove("q-1");
expect(mockService.deleteQuestion).toHaveBeenCalledWith("q-1"); expect(mockService.deleteQuestion).toHaveBeenCalledWith("q-1", undefined);
expect(result).toEqual({ success: true, data: { success: true } }); expect(result).toEqual({ success: true, data: { success: true } });
}); });
}); });

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
Param, Param,
Post, Post,
Put, Put,
@@ -10,6 +11,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { QuestionsService } from "./questions.service.js"; import { QuestionsService } from "./questions.service.js";
import type { Question } from "./questions.schema.js"; import type { Question } from "./questions.schema.js";
import type { QuestionWriteResult } from "./questions.service.js";
import { import {
Permissions, Permissions,
RequirePermission, RequirePermission,
@@ -21,6 +23,7 @@ import {
searchQuestionsSchema, searchQuestionsSchema,
rejectQuestionSchema, rejectQuestionSchema,
} from "./questions.dto.js"; } from "./questions.dto.js";
import { extractExpectedVersion } from "../shared/cache/version-header.js";
@Controller("questions") @Controller("questions")
export class QuestionsController { export class QuestionsController {
@@ -30,7 +33,7 @@ export class QuestionsController {
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE) @RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
async create( async create(
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: QuestionWriteResult }> {
const input = createQuestionSchema.parse(body); const input = createQuestionSchema.parse(body);
const result = await this.service.createQuestion(input); const result = await this.service.createQuestion(input);
return { success: true, data: result }; return { success: true, data: result };
@@ -90,18 +93,28 @@ export class QuestionsController {
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> { @Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: QuestionWriteResult }> {
const input = updateQuestionSchema.parse(body); const input = updateQuestionSchema.parse(body);
await this.service.updateQuestion(id, input); const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
return { success: true, data: { success: true } }; const result = await this.service.updateQuestion(
id,
input,
expectedVersion,
);
return { success: true, data: result };
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_DELETE) @RequirePermission(Permissions.CONTENT_QUESTION_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
@Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {
await this.service.deleteQuestion(id); const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
await this.service.deleteQuestion(id, expectedVersion);
return { success: true, data: { success: true } }; return { success: true, data: { success: true } };
} }

View File

@@ -3,9 +3,10 @@ import { QuestionsController } from "./questions.controller.js";
import { QuestionsService } from "./questions.service.js"; import { QuestionsService } from "./questions.service.js";
import { QuestionsRepository } from "./questions.repository.js"; import { QuestionsRepository } from "./questions.repository.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js";
import { CacheModule } from "../shared/cache/cache.module.js";
@Module({ @Module({
imports: [OutboxModule], imports: [OutboxModule, CacheModule],
controllers: [QuestionsController], controllers: [QuestionsController],
providers: [QuestionsService, QuestionsRepository], providers: [QuestionsService, QuestionsRepository],
exports: [QuestionsService, QuestionsRepository], exports: [QuestionsService, QuestionsRepository],

View File

@@ -40,6 +40,15 @@ const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"), publish: vi.fn().mockResolvedValue("event-id"),
}; };
const mockCacheInvalidation = {
invalidateTextbook: vi.fn().mockResolvedValue(undefined),
invalidateChapter: vi.fn().mockResolvedValue(undefined),
invalidateKnowledgePoint: vi.fn().mockResolvedValue(undefined),
invalidateQuestion: vi.fn().mockResolvedValue(undefined),
invalidateKeys: vi.fn().mockResolvedValue(undefined),
invalidatePattern: vi.fn().mockResolvedValue(undefined),
};
describe("QuestionsService", () => { describe("QuestionsService", () => {
let service: QuestionsService; let service: QuestionsService;
@@ -47,7 +56,10 @@ describe("QuestionsService", () => {
vi.clearAllMocks(); vi.clearAllMocks();
// 默认 ES 未配置searchQuestions 走 MySQL 降级路径 // 默认 ES 未配置searchQuestions 走 MySQL 降级路径
mockGetEsClient.mockReturnValue(null); mockGetEsClient.mockReturnValue(null);
service = new QuestionsService(mockOutbox as never); service = new QuestionsService(
mockOutbox as never,
mockCacheInvalidation as never,
);
}); });
describe("createQuestion", () => { describe("createQuestion", () => {
@@ -62,6 +74,7 @@ describe("QuestionsService", () => {
const result = await service.createQuestion(input); const result = await service.createQuestion(input);
expect(result.id).toBeDefined(); expect(result.id).toBeDefined();
expect(result.updatedAt).toBeInstanceOf(Date);
expect(questionsRepository.create).toHaveBeenCalledWith( expect(questionsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
knowledgePointId: "kp-1", knowledgePointId: "kp-1",

View File

@@ -3,8 +3,10 @@ import { Injectable, Logger } from "@nestjs/common";
import { questionsRepository } from "./questions.repository.js"; import { questionsRepository } from "./questions.repository.js";
import type { Question, NewQuestion } from "./questions.schema.js"; import type { Question, NewQuestion } from "./questions.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.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 { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
import { import {
ConflictError,
NotFoundError, NotFoundError,
ValidationError, ValidationError,
} from "../shared/errors/application-error.js"; } from "../shared/errors/application-error.js";
@@ -73,13 +75,24 @@ export interface SearchQuestionsResult {
total: number; total: number;
} }
/** 写操作返回结构,携带 updated_at 作为乐观锁版本ADR-039。 */
export interface QuestionWriteResult {
id: string;
updatedAt: Date;
}
@Injectable() @Injectable()
export class QuestionsService { export class QuestionsService {
private readonly logger = new Logger(QuestionsService.name); private readonly logger = new Logger(QuestionsService.name);
constructor(private readonly outbox: OutboxService) {} constructor(
private readonly outbox: OutboxService,
private readonly cacheInvalidation: EagerInvalidationService,
) {}
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> { async createQuestion(
input: CreateQuestionInput,
): Promise<QuestionWriteResult> {
const id = createId(); const id = createId();
const record: NewQuestion = { const record: NewQuestion = {
id, id,
@@ -111,7 +124,12 @@ export class QuestionsService {
}, },
); );
return { id }; // ADR-038: MySQL 提交后同步失效 Redis 查询缓存(软失败,不阻断)。
await this.cacheInvalidation.invalidateQuestion(id, input.knowledgePointId);
// 回查以获取 DB 生成的 updated_at乐观锁版本
const created = await questionsRepository.findById(id);
return { id, updatedAt: created?.updatedAt ?? new Date() };
} }
async getQuestion(id: string): Promise<Question> { async getQuestion(id: string): Promise<Question> {
@@ -130,8 +148,14 @@ export class QuestionsService {
return questionsRepository.find(query); return questionsRepository.find(query);
} }
async updateQuestion(id: string, data: UpdateQuestionInput): Promise<void> { async updateQuestion(
id: string,
data: UpdateQuestionInput,
expectedVersion?: string,
): Promise<QuestionWriteResult> {
const existing = await this.getQuestion(id); const existing = await this.getQuestion(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await questionsRepository.update(id, data); await questionsRepository.update(id, data);
const eventType = const eventType =
@@ -144,10 +168,20 @@ export class QuestionsService {
difficulty: data.difficulty ?? existing.difficulty, difficulty: data.difficulty ?? existing.difficulty,
status: data.status ?? existing.status, status: data.status ?? existing.status,
}); });
await this.cacheInvalidation.invalidateQuestion(
id,
existing.knowledgePointId,
);
const updated = await questionsRepository.findById(id);
return { id, updatedAt: updated?.updatedAt ?? new Date() };
} }
async deleteQuestion(id: string): Promise<void> { async deleteQuestion(id: string, expectedVersion?: string): Promise<void> {
await this.getQuestion(id); const existing = await this.getQuestion(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await questionsRepository.delete(id); await questionsRepository.delete(id);
await this.outbox.publish( await this.outbox.publish(
@@ -156,6 +190,11 @@ export class QuestionsService {
id, id,
{ deleted: true }, { deleted: true },
); );
await this.cacheInvalidation.invalidateQuestion(
id,
existing.knowledgePointId,
);
} }
// ========== P6.1: Question 审核工作流状态机 ========== // ========== P6.1: Question 审核工作流状态机 ==========
@@ -174,6 +213,12 @@ export class QuestionsService {
id, id,
{ status: "pending_review" }, { status: "pending_review" },
); );
// ADR-038: 状态机写操作后同步失效缓存(软失败)。
await this.cacheInvalidation.invalidateQuestion(
id,
question.knowledgePointId,
);
} }
/** /**
@@ -190,6 +235,12 @@ export class QuestionsService {
id, id,
{ status: "published" }, { status: "published" },
); );
// ADR-038: 状态机写操作后同步失效缓存(软失败)。
await this.cacheInvalidation.invalidateQuestion(
id,
question.knowledgePointId,
);
} }
/** /**
@@ -206,6 +257,12 @@ export class QuestionsService {
id, id,
{ status: "rejected", reject_reason: reason }, { status: "rejected", reject_reason: reason },
); );
// ADR-038: 状态机写操作后同步失效缓存(软失败)。
await this.cacheInvalidation.invalidateQuestion(
id,
question.knowledgePointId,
);
} }
/** /**
@@ -222,6 +279,12 @@ export class QuestionsService {
id, id,
{ status: "archived" }, { status: "archived" },
); );
// ADR-038: 状态机写操作后同步失效缓存(软失败)。
await this.cacheInvalidation.invalidateQuestion(
id,
question.knowledgePointId,
);
} }
/** /**
@@ -237,6 +300,26 @@ export class QuestionsService {
} }
} }
/**
* 乐观锁版本校验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(
"Question version mismatch (optimistic lock conflict)",
{
expected: expectedVersion,
current: currentUpdatedAt.toISOString(),
},
);
}
}
// ========== P5: 全文检索ES 优先MySQL 降级) ========== // ========== P5: 全文检索ES 优先MySQL 降级) ==========
/** /**

View File

@@ -0,0 +1,35 @@
/**
* Content 服务查询缓存 Key 约定ADR-038 Eager Invalidation
*
* Key 命名规范:
* - 实体单条:`content:<entity>:<id>`
* - 实体列表:`content:<entity>s:list` 或 `content:<entity>s:list:<parentId>`
*
* 写操作后,按实体粒度调用 EagerInvalidationService 失效对应 Key。
* 列表 Key 可能因查询参数不同存在多种变体,故列表类失效使用 pattern
* (如 `content:textbooks:list*`)兜底删除所有变体。
*/
export const ContentCacheKeys = {
/** 教材单条content:textbook:{id} */
textbook: (id: string): string => `content:textbook:${id}`,
/** 教材列表 pattern匹配 content:textbooks:list 及其带参变体 */
textbooksListPattern: (): string => `content:textbooks:list*`,
/** 章节单条content:chapter:{id} */
chapter: (id: string): string => `content:chapter:${id}`,
/** 章节列表content:chapters:list:{textbookId} */
chaptersList: (textbookId: string): string =>
`content:chapters:list:${textbookId}`,
/** 知识点单条content:knowledge-point:{id} */
knowledgePoint: (id: string): string => `content:knowledge-point:${id}`,
/** 知识点列表content:knowledge-points:list:{chapterId} */
knowledgePointsList: (chapterId: string): string =>
`content:knowledge-points:list:${chapterId}`,
/** 题目单条content:question:{id} */
question: (id: string): string => `content:question:${id}`,
/** 题目列表content:questions:list:{knowledgePointId} */
questionsList: (knowledgePointId: string): string =>
`content:questions:list:${knowledgePointId}`,
} as const;

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { EagerInvalidationService } from "./eager-invalidation.js";
@Module({
providers: [EagerInvalidationService],
exports: [EagerInvalidationService],
})
export class CacheModule {}

View File

@@ -0,0 +1,107 @@
import { Injectable, Logger } from "@nestjs/common";
import { getRedis } from "../../config/redis.js";
import { ContentCacheKeys } from "./cache-keys.js";
/**
* Eager Invalidation 服务ADR-038
*
* 在 MySQL 事务提交后、响应返回前,同步向 Redis 发送 DEL 命令删除查询缓存,
* 缩短 CQRS 读模型ES / Neo4j / Redis 缓存)与主库的最终一致窗口。
*
* 失败策略(软失败):
* - Redis 未配置REDIS_URL 缺失)→ 静默跳过,由 Kafka projector 兜底
* - Redis DEL / SCAN 抛错 → 记录 warn 日志,不阻断业务请求
*
* 该服务在写流程中被 await同步语义但任何异常都被吞掉非阻塞
*/
@Injectable()
export class EagerInvalidationService {
private readonly logger = new Logger(EagerInvalidationService.name);
/**
* 同步删除指定缓存 Key。
* Redis 不可用时静默跳过;删除出错仅记录 warn不抛异常。
*/
async invalidateKeys(keys: string[]): Promise<void> {
if (keys.length === 0) return;
const redis = getRedis();
if (!redis) return;
try {
await redis.del(...keys);
} catch (err) {
this.logger.warn(
`Eager invalidation (del) failed for keys [${keys.join(", ")}]: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* 扫描并删除匹配 pattern 的所有 KeySCAN + DEL避免 KEYS 阻塞)。
* Redis 不可用时静默跳过;出错仅记录 warn不抛异常。
*/
async invalidatePattern(pattern: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
let cursor = "0";
do {
const [nextCursor, batch] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100,
);
cursor = nextCursor;
if (batch.length > 0) {
await redis.del(...batch);
}
} while (cursor !== "0");
} catch (err) {
this.logger.warn(
`Eager invalidation (pattern) failed for ${pattern}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
// ========== 实体级便捷失效方法 ==========
/** 失效教材相关缓存:单条 + 列表 pattern */
async invalidateTextbook(id: string): Promise<void> {
await Promise.all([
this.invalidateKeys([ContentCacheKeys.textbook(id)]),
this.invalidatePattern(ContentCacheKeys.textbooksListPattern()),
]);
}
/** 失效章节相关缓存:单条 + 所属教材的章节列表 */
async invalidateChapter(id: string, textbookId: string): Promise<void> {
await this.invalidateKeys([
ContentCacheKeys.chapter(id),
ContentCacheKeys.chaptersList(textbookId),
]);
}
/** 失效知识点相关缓存:单条 + 所属章节的知识点列表 */
async invalidateKnowledgePoint(id: string, chapterId: string): Promise<void> {
await this.invalidateKeys([
ContentCacheKeys.knowledgePoint(id),
ContentCacheKeys.knowledgePointsList(chapterId),
]);
}
/** 失效题目相关缓存:单条 + 所属知识点的题目列表 */
async invalidateQuestion(
id: string,
knowledgePointId: string,
): Promise<void> {
await this.invalidateKeys([
ContentCacheKeys.question(id),
ContentCacheKeys.questionsList(knowledgePointId),
]);
}
}

View File

@@ -0,0 +1,18 @@
/**
* 乐观锁版本头解析ADR-039
*
* 客户端可通过以下任一请求头携带期望版本updated_at 的 ISO 字符串):
* - `X-Expected-Version`(自定义头,推荐,语义明确)
* - `If-Match`(标准 HTTP 头,可能以 ETag 形式带引号)
*
* 返回裸 ISO 字符串;未提供头时返回 undefined跳过版本校验
*/
export function extractExpectedVersion(
ifMatch?: string,
xExpectedVersion?: string,
): string | undefined {
const raw = xExpectedVersion ?? ifMatch;
if (!raw) return undefined;
// If-Match 可能以 ETag 形式带引号,去除首尾引号后返回裸 ISO 字符串。
return raw.replace(/^"|"$/g, "");
}

View File

@@ -68,9 +68,18 @@ describe("TextbooksController", () => {
describe("update", () => { describe("update", () => {
it("should parse input and call service.update", async () => { it("should parse input and call service.update", async () => {
const updatedAt = new Date();
mockService.update.mockResolvedValue({ id: "tb-1", updatedAt });
const result = await controller.update("tb-1", { title: "New" }); const result = await controller.update("tb-1", { title: "New" });
expect(mockService.update).toHaveBeenCalledWith("tb-1", { title: "New" }); expect(mockService.update).toHaveBeenCalledWith(
expect(result).toEqual({ success: true, data: { success: true } }); "tb-1",
{ title: "New" },
undefined,
);
expect(result).toEqual({
success: true,
data: { id: "tb-1", updatedAt },
});
}); });
it("should throw on invalid status", async () => { it("should throw on invalid status", async () => {
@@ -83,7 +92,7 @@ describe("TextbooksController", () => {
describe("remove", () => { describe("remove", () => {
it("should call service.delete", async () => { it("should call service.delete", async () => {
const result = await controller.remove("tb-1"); const result = await controller.remove("tb-1");
expect(mockService.delete).toHaveBeenCalledWith("tb-1"); expect(mockService.delete).toHaveBeenCalledWith("tb-1", undefined);
expect(result).toEqual({ success: true, data: { success: true } }); expect(result).toEqual({ success: true, data: { success: true } });
}); });
}); });

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
Param, Param,
Post, Post,
Put, Put,
@@ -19,6 +20,8 @@ import {
updateTextbookSchema, updateTextbookSchema,
listTextbooksSchema, listTextbooksSchema,
} from "./textbooks.dto.js"; } from "./textbooks.dto.js";
import type { WriteResult } from "./textbooks.service.js";
import { extractExpectedVersion } from "../shared/cache/version-header.js";
@Controller("textbooks") @Controller("textbooks")
export class TextbooksController { export class TextbooksController {
@@ -28,7 +31,7 @@ export class TextbooksController {
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE) @RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
async create( async create(
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: WriteResult }> {
const input = createTextbookSchema.parse(body); const input = createTextbookSchema.parse(body);
const result = await this.service.create(input); const result = await this.service.create(input);
return { success: true, data: result }; return { success: true, data: result };
@@ -72,18 +75,24 @@ export class TextbooksController {
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> { @Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: WriteResult }> {
const input = updateTextbookSchema.parse(body); const input = updateTextbookSchema.parse(body);
await this.service.update(id, input); const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
return { success: true, data: { success: true } }; const result = await this.service.update(id, input, expectedVersion);
return { success: true, data: result };
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_DELETE) @RequirePermission(Permissions.CONTENT_TEXTBOOK_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
@Headers("if-match") ifMatch?: string,
@Headers("x-expected-version") xExpectedVersion?: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {
await this.service.delete(id); const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion);
await this.service.delete(id, expectedVersion);
return { success: true, data: { success: true } }; return { success: true, data: { success: true } };
} }

View File

@@ -3,9 +3,10 @@ import { TextbooksController } from "./textbooks.controller.js";
import { TextbooksService } from "./textbooks.service.js"; import { TextbooksService } from "./textbooks.service.js";
import { TextbooksRepository } from "./textbooks.repository.js"; import { TextbooksRepository } from "./textbooks.repository.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js";
import { CacheModule } from "../shared/cache/cache.module.js";
@Module({ @Module({
imports: [OutboxModule], imports: [OutboxModule, CacheModule],
controllers: [TextbooksController], controllers: [TextbooksController],
providers: [TextbooksService, TextbooksRepository], providers: [TextbooksService, TextbooksRepository],
exports: [TextbooksService, TextbooksRepository], exports: [TextbooksService, TextbooksRepository],

View File

@@ -19,12 +19,24 @@ const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"), publish: vi.fn().mockResolvedValue("event-id"),
}; };
const mockCacheInvalidation = {
invalidateTextbook: vi.fn().mockResolvedValue(undefined),
invalidateChapter: vi.fn().mockResolvedValue(undefined),
invalidateKnowledgePoint: vi.fn().mockResolvedValue(undefined),
invalidateQuestion: vi.fn().mockResolvedValue(undefined),
invalidateKeys: vi.fn().mockResolvedValue(undefined),
invalidatePattern: vi.fn().mockResolvedValue(undefined),
};
describe("TextbooksService", () => { describe("TextbooksService", () => {
let service: TextbooksService; let service: TextbooksService;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
service = new TextbooksService(mockOutbox as never); service = new TextbooksService(
mockOutbox as never,
mockCacheInvalidation as never,
);
}); });
describe("create", () => { describe("create", () => {
@@ -38,6 +50,7 @@ describe("TextbooksService", () => {
expect(result.id).toBeDefined(); expect(result.id).toBeDefined();
expect(result.id).toHaveLength(24); // cuid2 length expect(result.id).toHaveLength(24); // cuid2 length
expect(result.updatedAt).toBeInstanceOf(Date);
expect(textbooksRepository.create).toHaveBeenCalledWith( expect(textbooksRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
title: "Math Grade 3", title: "Math Grade 3",

View File

@@ -3,8 +3,12 @@ import { Injectable } from "@nestjs/common";
import { textbooksRepository } from "./textbooks.repository.js"; import { textbooksRepository } from "./textbooks.repository.js";
import type { Textbook, NewTextbook } from "./textbooks.schema.js"; import type { Textbook, NewTextbook } from "./textbooks.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.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 { 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 { export interface CreateTextbookInput {
title: string; title: string;
@@ -27,11 +31,20 @@ export interface ListTextbooksInput {
pageSize?: number; pageSize?: number;
} }
/** 写操作返回结构,携带 updated_at 作为乐观锁版本ADR-039。 */
export interface WriteResult {
id: string;
updatedAt: Date;
}
@Injectable() @Injectable()
export class TextbooksService { 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 id = createId();
const record: NewTextbook = { const record: NewTextbook = {
id, 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[]> { async list(query?: ListTextbooksInput): Promise<Textbook[]> {
@@ -72,8 +90,14 @@ export class TextbooksService {
return result; 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); const existing = await this.getById(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await textbooksRepository.update(id, data); await textbooksRepository.update(id, data);
const eventType = const eventType =
@@ -88,10 +112,17 @@ export class TextbooksService {
status: data.status ?? existing.status, status: data.status ?? existing.status,
metadata: data.metadata ?? existing.metadata, 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> { async delete(id: string, expectedVersion?: string): Promise<void> {
await this.getById(id); const existing = await this.getById(id);
this.assertVersionMatch(existing.updatedAt, expectedVersion);
await textbooksRepository.delete(id); await textbooksRepository.delete(id);
// 教材删除视为归档事件,下游可感知失效 // 教材删除视为归档事件,下游可感知失效
await this.outbox.publish( await this.outbox.publish(
@@ -100,6 +131,8 @@ export class TextbooksService {
id, id,
{ deleted: true }, { deleted: true },
); );
await this.cacheInvalidation.invalidateTextbook(id);
} }
// ========== P6.3: 教材版本管理 ========== // ========== P6.3: 教材版本管理 ==========
@@ -124,6 +157,8 @@ export class TextbooksService {
previous_status: existing.status, previous_status: existing.status,
}, },
); );
await this.cacheInvalidation.invalidateTextbook(id);
} }
/** /**
@@ -133,4 +168,24 @@ export class TextbooksService {
async listVersions(subjectId: string, gradeId: string): Promise<Textbook[]> { async listVersions(subjectId: string, gradeId: string): Promise<Textbook[]> {
return textbooksRepository.findBySubjectAndGrade(subjectId, gradeId); 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(),
},
);
}
}
} }