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:
@@ -3,8 +3,12 @@ import { Injectable } from "@nestjs/common";
|
||||
import { textbooksRepository } from "./textbooks.repository.js";
|
||||
import type { Textbook, NewTextbook } from "./textbooks.schema.js";
|
||||
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
||||
import { EagerInvalidationService } from "../shared/cache/eager-invalidation.js";
|
||||
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
import {
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateTextbookInput {
|
||||
title: string;
|
||||
@@ -27,11 +31,20 @@ export interface ListTextbooksInput {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** 写操作返回结构,携带 updated_at 作为乐观锁版本(ADR-039)。 */
|
||||
export interface WriteResult {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TextbooksService {
|
||||
constructor(private readonly outbox: OutboxService) {}
|
||||
constructor(
|
||||
private readonly outbox: OutboxService,
|
||||
private readonly cacheInvalidation: EagerInvalidationService,
|
||||
) {}
|
||||
|
||||
async create(input: CreateTextbookInput): Promise<{ id: string }> {
|
||||
async create(input: CreateTextbookInput): Promise<WriteResult> {
|
||||
const id = createId();
|
||||
const record: NewTextbook = {
|
||||
id,
|
||||
@@ -57,7 +70,12 @@ export class TextbooksService {
|
||||
},
|
||||
);
|
||||
|
||||
return { id };
|
||||
// ADR-038: MySQL 提交后同步失效 Redis 查询缓存(软失败,不阻断)。
|
||||
await this.cacheInvalidation.invalidateTextbook(id);
|
||||
|
||||
// 回查以获取 DB 生成的 updated_at(乐观锁版本)。
|
||||
const created = await textbooksRepository.findById(id);
|
||||
return { id, updatedAt: created?.updatedAt ?? new Date() };
|
||||
}
|
||||
|
||||
async list(query?: ListTextbooksInput): Promise<Textbook[]> {
|
||||
@@ -72,8 +90,14 @@ export class TextbooksService {
|
||||
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);
|
||||
this.assertVersionMatch(existing.updatedAt, expectedVersion);
|
||||
|
||||
await textbooksRepository.update(id, data);
|
||||
|
||||
const eventType =
|
||||
@@ -88,10 +112,17 @@ export class TextbooksService {
|
||||
status: data.status ?? existing.status,
|
||||
metadata: data.metadata ?? existing.metadata,
|
||||
});
|
||||
|
||||
await this.cacheInvalidation.invalidateTextbook(id);
|
||||
|
||||
const updated = await textbooksRepository.findById(id);
|
||||
return { id, updatedAt: updated?.updatedAt ?? new Date() };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
async delete(id: string, expectedVersion?: string): Promise<void> {
|
||||
const existing = await this.getById(id);
|
||||
this.assertVersionMatch(existing.updatedAt, expectedVersion);
|
||||
|
||||
await textbooksRepository.delete(id);
|
||||
// 教材删除视为归档事件,下游可感知失效
|
||||
await this.outbox.publish(
|
||||
@@ -100,6 +131,8 @@ export class TextbooksService {
|
||||
id,
|
||||
{ deleted: true },
|
||||
);
|
||||
|
||||
await this.cacheInvalidation.invalidateTextbook(id);
|
||||
}
|
||||
|
||||
// ========== P6.3: 教材版本管理 ==========
|
||||
@@ -124,6 +157,8 @@ export class TextbooksService {
|
||||
previous_status: existing.status,
|
||||
},
|
||||
);
|
||||
|
||||
await this.cacheInvalidation.invalidateTextbook(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,4 +168,24 @@ export class TextbooksService {
|
||||
async listVersions(subjectId: string, gradeId: string): Promise<Textbook[]> {
|
||||
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