diff --git a/services/content/package.json b/services/content/package.json index b7645f9..f9d5968 100644 --- a/services/content/package.json +++ b/services/content/package.json @@ -31,6 +31,7 @@ "dataloader": "^2.2.2", "drizzle-orm": "^0.31.0", "graphql": "^16.9.0", + "ioredis": "^5.4.0", "kafkajs": "^2.2.4", "mysql2": "^3.11.0", "neo4j-driver": "^5.23.0", diff --git a/services/content/src/chapters/chapters.controller.test.ts b/services/content/src/chapters/chapters.controller.test.ts index 951ce61..66c04ff 100644 --- a/services/content/src/chapters/chapters.controller.test.ts +++ b/services/content/src/chapters/chapters.controller.test.ts @@ -65,11 +65,18 @@ describe("ChaptersController", () => { describe("update", () => { 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" }); - expect(mockService.updateChapter).toHaveBeenCalledWith("ch-1", { - title: "New", + expect(mockService.updateChapter).toHaveBeenCalledWith( + "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 () => { @@ -82,7 +89,7 @@ describe("ChaptersController", () => { describe("remove", () => { it("should call service.deleteChapter", async () => { 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 } }); }); }); diff --git a/services/content/src/chapters/chapters.controller.ts b/services/content/src/chapters/chapters.controller.ts index a7fa913..ff68db6 100644 --- a/services/content/src/chapters/chapters.controller.ts +++ b/services/content/src/chapters/chapters.controller.ts @@ -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 } }; } } diff --git a/services/content/src/chapters/chapters.module.ts b/services/content/src/chapters/chapters.module.ts index f41ddaa..c97cfee 100644 --- a/services/content/src/chapters/chapters.module.ts +++ b/services/content/src/chapters/chapters.module.ts @@ -3,9 +3,10 @@ import { ChaptersController } from "./chapters.controller.js"; import { ChaptersService } from "./chapters.service.js"; import { ChaptersRepository } from "./chapters.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; +import { CacheModule } from "../shared/cache/cache.module.js"; @Module({ - imports: [OutboxModule], + imports: [OutboxModule, CacheModule], controllers: [ChaptersController], providers: [ChaptersService, ChaptersRepository], exports: [ChaptersService, ChaptersRepository], diff --git a/services/content/src/chapters/chapters.service.test.ts b/services/content/src/chapters/chapters.service.test.ts index a170e3d..ff9d386 100644 --- a/services/content/src/chapters/chapters.service.test.ts +++ b/services/content/src/chapters/chapters.service.test.ts @@ -18,12 +18,24 @@ const mockOutbox = { 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", () => { let service: ChaptersService; beforeEach(() => { vi.clearAllMocks(); - service = new ChaptersService(mockOutbox as never); + service = new ChaptersService( + mockOutbox as never, + mockCacheInvalidation as never, + ); }); describe("createChapter", () => { @@ -36,6 +48,7 @@ describe("ChaptersService", () => { const result = await service.createChapter(input); expect(result.id).toBeDefined(); + expect(result.updatedAt).toBeInstanceOf(Date); expect(chaptersRepository.create).toHaveBeenCalledWith( expect.objectContaining({ textbookId: "tb-1", diff --git a/services/content/src/chapters/chapters.service.ts b/services/content/src/chapters/chapters.service.ts index 478a77f..e0af8fd 100644 --- a/services/content/src/chapters/chapters.service.ts +++ b/services/content/src/chapters/chapters.service.ts @@ -3,8 +3,12 @@ import { Injectable } from "@nestjs/common"; import { chaptersRepository } from "./chapters.repository.js"; import type { Chapter, NewChapter } from "./chapters.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 CreateChapterInput { textbookId: string; @@ -19,11 +23,20 @@ export interface UpdateChapterInput { status?: string; } +/** 写操作返回结构,携带 updated_at 作为乐观锁版本(ADR-039)。 */ +export interface ChapterWriteResult { + id: string; + updatedAt: Date; +} + @Injectable() 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 { const id = createId(); const record: NewChapter = { 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 { @@ -62,8 +80,14 @@ export class ChaptersService { return chaptersRepository.findByTextbookId(textbookId); } - async updateChapter(id: string, data: UpdateChapterInput): Promise { + async updateChapter( + id: string, + data: UpdateChapterInput, + expectedVersion?: string, + ): Promise { const existing = await this.getChapter(id); + this.assertVersionMatch(existing.updatedAt, expectedVersion); + await chaptersRepository.update(id, data); await this.outbox.publish( @@ -76,10 +100,17 @@ export class ChaptersService { 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 { - await this.getChapter(id); + async deleteChapter(id: string, expectedVersion?: string): Promise { + const existing = await this.getChapter(id); + this.assertVersionMatch(existing.updatedAt, expectedVersion); + await chaptersRepository.delete(id); await this.outbox.publish( @@ -88,5 +119,27 @@ export class ChaptersService { id, { deleted: true }, ); + + await this.cacheInvalidation.invalidateChapter(id, existing.textbookId); + } + + /** + * 乐观锁版本校验(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( + "Chapter version mismatch (optimistic lock conflict)", + { + expected: expectedVersion, + current: currentUpdatedAt.toISOString(), + }, + ); + } } } diff --git a/services/content/src/config/redis.ts b/services/content/src/config/redis.ts new file mode 100644 index 0000000..688b52e --- /dev/null +++ b/services/content/src/config/redis.ts @@ -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 { + if (client) { + await client.quit(); + client = null; + } +} diff --git a/services/content/src/knowledge-points/knowledge-points.controller.test.ts b/services/content/src/knowledge-points/knowledge-points.controller.test.ts index 4573c2c..e8e3cf2 100644 --- a/services/content/src/knowledge-points/knowledge-points.controller.test.ts +++ b/services/content/src/knowledge-points/knowledge-points.controller.test.ts @@ -108,18 +108,31 @@ describe("KnowledgePointsController", () => { describe("update", () => { it("should parse input and call service.updateKnowledgePoint", async () => { - const result = await controller.update("kp-1", { title: "New" }); - expect(mockService.updateKnowledgePoint).toHaveBeenCalledWith("kp-1", { - title: "New", + const updatedAt = new Date(); + mockService.updateKnowledgePoint.mockResolvedValue({ + 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", () => { it("should call service.deleteKnowledgePoint", async () => { 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 } }); }); }); diff --git a/services/content/src/knowledge-points/knowledge-points.controller.ts b/services/content/src/knowledge-points/knowledge-points.controller.ts index 7f6e8d8..489f08b 100644 --- a/services/content/src/knowledge-points/knowledge-points.controller.ts +++ b/services/content/src/knowledge-points/knowledge-points.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Headers, Param, Post, Put, @@ -12,6 +13,7 @@ import { KnowledgePointsService, type PrerequisiteNode, type VisualizationResult, + type KnowledgePointWriteResult, } from "./knowledge-points.service.js"; import type { KnowledgePoint } from "./knowledge-points.schema.js"; import { @@ -23,6 +25,7 @@ import { updateKnowledgePointSchema, addPrerequisiteSchema, } from "./knowledge-points.dto.js"; +import { extractExpectedVersion } from "../shared/cache/version-header.js"; @Controller("knowledge-points") export class KnowledgePointsController { @@ -32,7 +35,7 @@ export class KnowledgePointsController { @RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE) async create( @Body() body: unknown, - ): Promise<{ success: true; data: { id: string } }> { + ): Promise<{ success: true; data: KnowledgePointWriteResult }> { const input = createKnowledgePointSchema.parse(body); const result = await this.service.createKnowledgePoint(input); return { success: true, data: result }; @@ -91,18 +94,28 @@ export class KnowledgePointsController { 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: KnowledgePointWriteResult }> { const input = updateKnowledgePointSchema.parse(body); - await this.service.updateKnowledgePoint(id, input); - return { success: true, data: { success: true } }; + const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion); + const result = await this.service.updateKnowledgePoint( + id, + input, + expectedVersion, + ); + return { success: true, data: result }; } @Delete(":id") @RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_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.deleteKnowledgePoint(id); + const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion); + await this.service.deleteKnowledgePoint(id, expectedVersion); return { success: true, data: { success: true } }; } } diff --git a/services/content/src/knowledge-points/knowledge-points.module.ts b/services/content/src/knowledge-points/knowledge-points.module.ts index 5800ae5..a8f237c 100644 --- a/services/content/src/knowledge-points/knowledge-points.module.ts +++ b/services/content/src/knowledge-points/knowledge-points.module.ts @@ -6,9 +6,10 @@ import { import { KnowledgePointsService } from "./knowledge-points.service.js"; import { KnowledgePointsRepository } from "./knowledge-points.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; +import { CacheModule } from "../shared/cache/cache.module.js"; @Module({ - imports: [OutboxModule], + imports: [OutboxModule, CacheModule], controllers: [KnowledgePointsController, KnowledgeGraphController], providers: [KnowledgePointsService, KnowledgePointsRepository], exports: [KnowledgePointsService, KnowledgePointsRepository], diff --git a/services/content/src/knowledge-points/knowledge-points.service.test.ts b/services/content/src/knowledge-points/knowledge-points.service.test.ts index 94d470b..0a25bcf 100644 --- a/services/content/src/knowledge-points/knowledge-points.service.test.ts +++ b/services/content/src/knowledge-points/knowledge-points.service.test.ts @@ -26,12 +26,24 @@ const mockOutbox = { 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", () => { let service: KnowledgePointsService; beforeEach(() => { vi.clearAllMocks(); - service = new KnowledgePointsService(mockOutbox as never); + service = new KnowledgePointsService( + mockOutbox as never, + mockCacheInvalidation as never, + ); }); describe("createKnowledgePoint", () => { @@ -45,6 +57,7 @@ describe("KnowledgePointsService", () => { const result = await service.createKnowledgePoint(input); expect(result.id).toBeDefined(); + expect(result.updatedAt).toBeInstanceOf(Date); expect(knowledgePointsRepository.create).toHaveBeenCalledWith( expect.objectContaining({ chapterId: "ch-1", diff --git a/services/content/src/knowledge-points/knowledge-points.service.ts b/services/content/src/knowledge-points/knowledge-points.service.ts index 6485bb3..3edb9ff 100644 --- a/services/content/src/knowledge-points/knowledge-points.service.ts +++ b/services/content/src/knowledge-points/knowledge-points.service.ts @@ -6,9 +6,11 @@ import type { NewKnowledgePoint, } from "./knowledge-points.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 { getNeo4jSession } from "../config/neo4j.js"; import { + ConflictError, NotFoundError, ValidationError, } from "../shared/errors/application-error.js"; @@ -51,15 +53,24 @@ export interface VisualizationResult { edges: VisualizationEdge[]; } +/** 写操作返回结构,携带 updated_at 作为乐观锁版本(ADR-039)。 */ +export interface KnowledgePointWriteResult { + id: string; + updatedAt: Date; +} + @Injectable() export class KnowledgePointsService { private readonly logger = new Logger(KnowledgePointsService.name); - constructor(private readonly outbox: OutboxService) {} + constructor( + private readonly outbox: OutboxService, + private readonly cacheInvalidation: EagerInvalidationService, + ) {} async createKnowledgePoint( input: CreateKnowledgePointInput, - ): Promise<{ id: string }> { + ): Promise { const id = createId(); const record: NewKnowledgePoint = { 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 { @@ -120,8 +136,11 @@ export class KnowledgePointsService { async updateKnowledgePoint( id: string, data: UpdateKnowledgePointInput, - ): Promise { + expectedVersion?: string, + ): Promise { const existing = await this.getKnowledgePoint(id); + this.assertVersionMatch(existing.updatedAt, expectedVersion); + await knowledgePointsRepository.update(id, data); await this.outbox.publish( @@ -134,10 +153,23 @@ export class KnowledgePointsService { 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 { - await this.getKnowledgePoint(id); + async deleteKnowledgePoint( + id: string, + expectedVersion?: string, + ): Promise { + const existing = await this.getKnowledgePoint(id); + this.assertVersionMatch(existing.updatedAt, expectedVersion); + await knowledgePointsRepository.delete(id); // 知识点删除事件也走 kp.updated(下游可标记节点为失效) await this.outbox.publish( @@ -146,6 +178,11 @@ export class KnowledgePointsService { id, { deleted: true }, ); + + await this.cacheInvalidation.invalidateKnowledgePoint( + id, + existing.chapterId, + ); } /** @@ -366,4 +403,24 @@ export class KnowledgePointsService { const v = obj[key]; return typeof v === "string" ? v : undefined; } + + /** + * 乐观锁版本校验(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( + "KnowledgePoint version mismatch (optimistic lock conflict)", + { + expected: expectedVersion, + current: currentUpdatedAt.toISOString(), + }, + ); + } + } } diff --git a/services/content/src/questions/questions.controller.test.ts b/services/content/src/questions/questions.controller.test.ts index d63541b..7ad0eb7 100644 --- a/services/content/src/questions/questions.controller.test.ts +++ b/services/content/src/questions/questions.controller.test.ts @@ -29,7 +29,8 @@ describe("QuestionsController", () => { describe("create", () => { 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({ knowledgePointId: "kp-1", type: "single_choice", @@ -44,7 +45,10 @@ describe("QuestionsController", () => { 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 () => { @@ -101,11 +105,18 @@ describe("QuestionsController", () => { describe("update", () => { 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" }); - expect(mockService.updateQuestion).toHaveBeenCalledWith("q-1", { - content: "New content", + expect(mockService.updateQuestion).toHaveBeenCalledWith( + "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 () => { @@ -118,7 +129,7 @@ describe("QuestionsController", () => { describe("remove", () => { it("should call service.deleteQuestion", async () => { 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 } }); }); }); diff --git a/services/content/src/questions/questions.controller.ts b/services/content/src/questions/questions.controller.ts index 63cd77d..d36487b 100644 --- a/services/content/src/questions/questions.controller.ts +++ b/services/content/src/questions/questions.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Headers, Param, Post, Put, @@ -10,6 +11,7 @@ import { } from "@nestjs/common"; import { QuestionsService } from "./questions.service.js"; import type { Question } from "./questions.schema.js"; +import type { QuestionWriteResult } from "./questions.service.js"; import { Permissions, RequirePermission, @@ -21,6 +23,7 @@ import { searchQuestionsSchema, rejectQuestionSchema, } from "./questions.dto.js"; +import { extractExpectedVersion } from "../shared/cache/version-header.js"; @Controller("questions") export class QuestionsController { @@ -30,7 +33,7 @@ export class QuestionsController { @RequirePermission(Permissions.CONTENT_QUESTION_CREATE) async create( @Body() body: unknown, - ): Promise<{ success: true; data: { id: string } }> { + ): Promise<{ success: true; data: QuestionWriteResult }> { const input = createQuestionSchema.parse(body); const result = await this.service.createQuestion(input); return { success: true, data: result }; @@ -90,18 +93,28 @@ export class QuestionsController { 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: QuestionWriteResult }> { const input = updateQuestionSchema.parse(body); - await this.service.updateQuestion(id, input); - return { success: true, data: { success: true } }; + const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion); + const result = await this.service.updateQuestion( + id, + input, + expectedVersion, + ); + return { success: true, data: result }; } @Delete(":id") @RequirePermission(Permissions.CONTENT_QUESTION_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.deleteQuestion(id); + const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion); + await this.service.deleteQuestion(id, expectedVersion); return { success: true, data: { success: true } }; } diff --git a/services/content/src/questions/questions.module.ts b/services/content/src/questions/questions.module.ts index 93c5c5c..b701c95 100644 --- a/services/content/src/questions/questions.module.ts +++ b/services/content/src/questions/questions.module.ts @@ -3,9 +3,10 @@ import { QuestionsController } from "./questions.controller.js"; import { QuestionsService } from "./questions.service.js"; import { QuestionsRepository } from "./questions.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; +import { CacheModule } from "../shared/cache/cache.module.js"; @Module({ - imports: [OutboxModule], + imports: [OutboxModule, CacheModule], controllers: [QuestionsController], providers: [QuestionsService, QuestionsRepository], exports: [QuestionsService, QuestionsRepository], diff --git a/services/content/src/questions/questions.service.test.ts b/services/content/src/questions/questions.service.test.ts index 2e71e9d..fef7d1c 100644 --- a/services/content/src/questions/questions.service.test.ts +++ b/services/content/src/questions/questions.service.test.ts @@ -40,6 +40,15 @@ const mockOutbox = { 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", () => { let service: QuestionsService; @@ -47,7 +56,10 @@ describe("QuestionsService", () => { vi.clearAllMocks(); // 默认 ES 未配置:searchQuestions 走 MySQL 降级路径 mockGetEsClient.mockReturnValue(null); - service = new QuestionsService(mockOutbox as never); + service = new QuestionsService( + mockOutbox as never, + mockCacheInvalidation as never, + ); }); describe("createQuestion", () => { @@ -62,6 +74,7 @@ describe("QuestionsService", () => { const result = await service.createQuestion(input); expect(result.id).toBeDefined(); + expect(result.updatedAt).toBeInstanceOf(Date); expect(questionsRepository.create).toHaveBeenCalledWith( expect.objectContaining({ knowledgePointId: "kp-1", diff --git a/services/content/src/questions/questions.service.ts b/services/content/src/questions/questions.service.ts index 573241d..66f5dd6 100644 --- a/services/content/src/questions/questions.service.ts +++ b/services/content/src/questions/questions.service.ts @@ -3,8 +3,10 @@ import { Injectable, Logger } from "@nestjs/common"; import { questionsRepository } from "./questions.repository.js"; import type { Question, NewQuestion } from "./questions.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, ValidationError, } from "../shared/errors/application-error.js"; @@ -73,13 +75,24 @@ export interface SearchQuestionsResult { total: number; } +/** 写操作返回结构,携带 updated_at 作为乐观锁版本(ADR-039)。 */ +export interface QuestionWriteResult { + id: string; + updatedAt: Date; +} + @Injectable() export class QuestionsService { 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 { const id = createId(); const record: NewQuestion = { 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 { @@ -130,8 +148,14 @@ export class QuestionsService { return questionsRepository.find(query); } - async updateQuestion(id: string, data: UpdateQuestionInput): Promise { + async updateQuestion( + id: string, + data: UpdateQuestionInput, + expectedVersion?: string, + ): Promise { const existing = await this.getQuestion(id); + this.assertVersionMatch(existing.updatedAt, expectedVersion); + await questionsRepository.update(id, data); const eventType = @@ -144,10 +168,20 @@ export class QuestionsService { difficulty: data.difficulty ?? existing.difficulty, 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 { - await this.getQuestion(id); + async deleteQuestion(id: string, expectedVersion?: string): Promise { + const existing = await this.getQuestion(id); + this.assertVersionMatch(existing.updatedAt, expectedVersion); + await questionsRepository.delete(id); await this.outbox.publish( @@ -156,6 +190,11 @@ export class QuestionsService { id, { deleted: true }, ); + + await this.cacheInvalidation.invalidateQuestion( + id, + existing.knowledgePointId, + ); } // ========== P6.1: Question 审核工作流状态机 ========== @@ -174,6 +213,12 @@ export class QuestionsService { id, { status: "pending_review" }, ); + + // ADR-038: 状态机写操作后同步失效缓存(软失败)。 + await this.cacheInvalidation.invalidateQuestion( + id, + question.knowledgePointId, + ); } /** @@ -190,6 +235,12 @@ export class QuestionsService { id, { status: "published" }, ); + + // ADR-038: 状态机写操作后同步失效缓存(软失败)。 + await this.cacheInvalidation.invalidateQuestion( + id, + question.knowledgePointId, + ); } /** @@ -206,6 +257,12 @@ export class QuestionsService { id, { status: "rejected", reject_reason: reason }, ); + + // ADR-038: 状态机写操作后同步失效缓存(软失败)。 + await this.cacheInvalidation.invalidateQuestion( + id, + question.knowledgePointId, + ); } /** @@ -222,6 +279,12 @@ export class QuestionsService { id, { status: "archived" }, ); + + // ADR-038: 状态机写操作后同步失效缓存(软失败)。 + await this.cacheInvalidation.invalidateQuestion( + id, + question.knowledgePointId, + ); } /** @@ -237,6 +300,26 @@ export class QuestionsService { } } + /** + * 乐观锁版本校验(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( + "Question version mismatch (optimistic lock conflict)", + { + expected: expectedVersion, + current: currentUpdatedAt.toISOString(), + }, + ); + } + } + // ========== P5: 全文检索(ES 优先,MySQL 降级) ========== /** diff --git a/services/content/src/shared/cache/cache-keys.ts b/services/content/src/shared/cache/cache-keys.ts new file mode 100644 index 0000000..bf68109 --- /dev/null +++ b/services/content/src/shared/cache/cache-keys.ts @@ -0,0 +1,35 @@ +/** + * Content 服务查询缓存 Key 约定(ADR-038 Eager Invalidation)。 + * + * Key 命名规范: + * - 实体单条:`content::` + * - 实体列表:`content:s:list` 或 `content:s:list:` + * + * 写操作后,按实体粒度调用 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; diff --git a/services/content/src/shared/cache/cache.module.ts b/services/content/src/shared/cache/cache.module.ts new file mode 100644 index 0000000..cb4424b --- /dev/null +++ b/services/content/src/shared/cache/cache.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { EagerInvalidationService } from "./eager-invalidation.js"; + +@Module({ + providers: [EagerInvalidationService], + exports: [EagerInvalidationService], +}) +export class CacheModule {} diff --git a/services/content/src/shared/cache/eager-invalidation.ts b/services/content/src/shared/cache/eager-invalidation.ts new file mode 100644 index 0000000..a53ae34 --- /dev/null +++ b/services/content/src/shared/cache/eager-invalidation.ts @@ -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 { + 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 的所有 Key(SCAN + DEL,避免 KEYS 阻塞)。 + * Redis 不可用时静默跳过;出错仅记录 warn,不抛异常。 + */ + async invalidatePattern(pattern: string): Promise { + 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 { + await Promise.all([ + this.invalidateKeys([ContentCacheKeys.textbook(id)]), + this.invalidatePattern(ContentCacheKeys.textbooksListPattern()), + ]); + } + + /** 失效章节相关缓存:单条 + 所属教材的章节列表 */ + async invalidateChapter(id: string, textbookId: string): Promise { + await this.invalidateKeys([ + ContentCacheKeys.chapter(id), + ContentCacheKeys.chaptersList(textbookId), + ]); + } + + /** 失效知识点相关缓存:单条 + 所属章节的知识点列表 */ + async invalidateKnowledgePoint(id: string, chapterId: string): Promise { + await this.invalidateKeys([ + ContentCacheKeys.knowledgePoint(id), + ContentCacheKeys.knowledgePointsList(chapterId), + ]); + } + + /** 失效题目相关缓存:单条 + 所属知识点的题目列表 */ + async invalidateQuestion( + id: string, + knowledgePointId: string, + ): Promise { + await this.invalidateKeys([ + ContentCacheKeys.question(id), + ContentCacheKeys.questionsList(knowledgePointId), + ]); + } +} diff --git a/services/content/src/shared/cache/version-header.ts b/services/content/src/shared/cache/version-header.ts new file mode 100644 index 0000000..5be623f --- /dev/null +++ b/services/content/src/shared/cache/version-header.ts @@ -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, ""); +} diff --git a/services/content/src/textbooks/textbooks.controller.test.ts b/services/content/src/textbooks/textbooks.controller.test.ts index bb660ee..2e2ad5f 100644 --- a/services/content/src/textbooks/textbooks.controller.test.ts +++ b/services/content/src/textbooks/textbooks.controller.test.ts @@ -68,9 +68,18 @@ describe("TextbooksController", () => { describe("update", () => { 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" }); - expect(mockService.update).toHaveBeenCalledWith("tb-1", { title: "New" }); - expect(result).toEqual({ success: true, data: { success: true } }); + expect(mockService.update).toHaveBeenCalledWith( + "tb-1", + { title: "New" }, + undefined, + ); + expect(result).toEqual({ + success: true, + data: { id: "tb-1", updatedAt }, + }); }); it("should throw on invalid status", async () => { @@ -83,7 +92,7 @@ describe("TextbooksController", () => { describe("remove", () => { it("should call service.delete", async () => { 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 } }); }); }); diff --git a/services/content/src/textbooks/textbooks.controller.ts b/services/content/src/textbooks/textbooks.controller.ts index 78e29b0..c6be48b 100644 --- a/services/content/src/textbooks/textbooks.controller.ts +++ b/services/content/src/textbooks/textbooks.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Headers, Param, Post, Put, @@ -19,6 +20,8 @@ import { updateTextbookSchema, listTextbooksSchema, } from "./textbooks.dto.js"; +import type { WriteResult } from "./textbooks.service.js"; +import { extractExpectedVersion } from "../shared/cache/version-header.js"; @Controller("textbooks") export class TextbooksController { @@ -28,7 +31,7 @@ export class TextbooksController { @RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE) async create( @Body() body: unknown, - ): Promise<{ success: true; data: { id: string } }> { + ): Promise<{ success: true; data: WriteResult }> { const input = createTextbookSchema.parse(body); const result = await this.service.create(input); return { success: true, data: result }; @@ -72,18 +75,24 @@ export class TextbooksController { 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: WriteResult }> { const input = updateTextbookSchema.parse(body); - await this.service.update(id, input); - return { success: true, data: { success: true } }; + const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion); + const result = await this.service.update(id, input, expectedVersion); + return { success: true, data: result }; } @Delete(":id") @RequirePermission(Permissions.CONTENT_TEXTBOOK_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.delete(id); + const expectedVersion = extractExpectedVersion(ifMatch, xExpectedVersion); + await this.service.delete(id, expectedVersion); return { success: true, data: { success: true } }; } diff --git a/services/content/src/textbooks/textbooks.module.ts b/services/content/src/textbooks/textbooks.module.ts index 7d53c27..7af2034 100644 --- a/services/content/src/textbooks/textbooks.module.ts +++ b/services/content/src/textbooks/textbooks.module.ts @@ -3,9 +3,10 @@ import { TextbooksController } from "./textbooks.controller.js"; import { TextbooksService } from "./textbooks.service.js"; import { TextbooksRepository } from "./textbooks.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; +import { CacheModule } from "../shared/cache/cache.module.js"; @Module({ - imports: [OutboxModule], + imports: [OutboxModule, CacheModule], controllers: [TextbooksController], providers: [TextbooksService, TextbooksRepository], exports: [TextbooksService, TextbooksRepository], diff --git a/services/content/src/textbooks/textbooks.service.test.ts b/services/content/src/textbooks/textbooks.service.test.ts index f36acc5..acaf727 100644 --- a/services/content/src/textbooks/textbooks.service.test.ts +++ b/services/content/src/textbooks/textbooks.service.test.ts @@ -19,12 +19,24 @@ const mockOutbox = { 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", () => { let service: TextbooksService; beforeEach(() => { vi.clearAllMocks(); - service = new TextbooksService(mockOutbox as never); + service = new TextbooksService( + mockOutbox as never, + mockCacheInvalidation as never, + ); }); describe("create", () => { @@ -38,6 +50,7 @@ describe("TextbooksService", () => { expect(result.id).toBeDefined(); expect(result.id).toHaveLength(24); // cuid2 length + expect(result.updatedAt).toBeInstanceOf(Date); expect(textbooksRepository.create).toHaveBeenCalledWith( expect.objectContaining({ title: "Math Grade 3", diff --git a/services/content/src/textbooks/textbooks.service.ts b/services/content/src/textbooks/textbooks.service.ts index 3aecc67..2003f2b 100644 --- a/services/content/src/textbooks/textbooks.service.ts +++ b/services/content/src/textbooks/textbooks.service.ts @@ -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 { 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 { @@ -72,8 +90,14 @@ export class TextbooksService { return result; } - async update(id: string, data: UpdateTextbookInput): Promise { + async update( + id: string, + data: UpdateTextbookInput, + expectedVersion?: string, + ): Promise { 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 { - await this.getById(id); + async delete(id: string, expectedVersion?: string): Promise { + 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 { 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(), + }, + ); + } + } }