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:
@@ -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",
|
||||||
|
|||||||
@@ -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 } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
services/content/src/config/redis.ts
Normal file
44
services/content/src/config/redis.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-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 降级) ==========
|
// ========== P5: 全文检索(ES 优先,MySQL 降级) ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
35
services/content/src/shared/cache/cache-keys.ts
vendored
Normal file
35
services/content/src/shared/cache/cache-keys.ts
vendored
Normal 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;
|
||||||
8
services/content/src/shared/cache/cache.module.ts
vendored
Normal file
8
services/content/src/shared/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { EagerInvalidationService } from "./eager-invalidation.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [EagerInvalidationService],
|
||||||
|
exports: [EagerInvalidationService],
|
||||||
|
})
|
||||||
|
export class CacheModule {}
|
||||||
107
services/content/src/shared/cache/eager-invalidation.ts
vendored
Normal file
107
services/content/src/shared/cache/eager-invalidation.ts
vendored
Normal 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 的所有 Key(SCAN + 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
services/content/src/shared/cache/version-header.ts
vendored
Normal file
18
services/content/src/shared/cache/version-header.ts
vendored
Normal 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, "");
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user