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:
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-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 降级) ==========
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user