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

@@ -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 } });
});
});

View File

@@ -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 } };
}

View File

@@ -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],

View File

@@ -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",

View File

@@ -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<QuestionWriteResult> {
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<Question> {
@@ -130,8 +148,14 @@ export class QuestionsService {
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);
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<void> {
await this.getQuestion(id);
async deleteQuestion(id: string, expectedVersion?: string): Promise<void> {
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-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 降级) ==========
/**