feat(sync): cn event coverage, bootstrap and outbox sender

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 15:21:41 +08:00
parent 3d6e9ce175
commit 65726702f3
43 changed files with 3333 additions and 181 deletions
+3
View File
@@ -14,6 +14,9 @@ module.exports = {
target: 'es2022',
strict: true,
esModuleInterop: true,
// 覆盖包级 tsconfig 的 types 限制(其值仅供 src 构建侧收敛 node 类型):
// 测试编译需要 jest 全局与 node 类型(internal-sync 的 Buffer/测试时钟)。
types: ['node', 'jest'],
},
},
],
+1
View File
@@ -1480,6 +1480,7 @@ components:
- PHOTO_NOT_FOUND
- PHOTO_NOT_PUBLIC
- VALIDATION_FAILED
- SYNC_AUTH_FAILED
- INTERNAL_ERROR
message:
type: string
+1
View File
@@ -26,6 +26,7 @@
"devDependencies": {
"@redocly/cli": "^1.0.0",
"@types/jest": "^29.5.0",
"@types/node": "^22.10.0",
"jest": "^29.7.0",
"openapi-typescript": "^7.0.0",
"ts-jest": "^29.2.0",
@@ -59,13 +59,16 @@ describe('OutboxEvent', () => {
});
describe('OUTBOX_EVENT_TYPES', () => {
it('恰好包含全部 5 个事件类型字符串', () => {
it('恰好包含全部 8 个事件类型字符串', () => {
expect([...OUTBOX_EVENT_TYPES]).toEqual([
'photo.published',
'photo.hidden',
'photo.deleted',
'photo.likes.updated',
'album.updated',
'category.updated',
'category.deleted',
'asset.updated',
]);
});
+4
View File
@@ -24,6 +24,10 @@ export const ErrorCodes = {
PHOTO_NOT_FOUND: 'PHOTO_NOT_FOUND',
PHOTO_NOT_PUBLIC: 'PHOTO_NOT_PUBLIC',
VALIDATION_FAILED: 'VALIDATION_FAILED',
// P2 Task 3:内部同步接口(/internal/**)HMAC 鉴权失败的 401。AUTH_TOKEN_EXPIRED
// 语义绑死观众/员工会话,服务间签名失败复用它会对客户端撒谎;与 openapi.yaml
// Error enum 同步增补(1:1 守护见 __tests__/events.test.ts)。
SYNC_AUTH_FAILED: 'SYNC_AUTH_FAILED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
+114 -3
View File
@@ -1,7 +1,7 @@
// packages/contracts/src/events.ts
// Outbox 事件契约(docs/03-技术实施方案.md §8.2):P1a 在业务事务内写 outbox,
// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。payload 由各事件
// 类型自行约定,此处仅落 P2 展示一张照片所需的 photo.published 载荷(YAGNI)。
// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。P2 Task 3 起补全
// album/category/asset 载荷(cn 事件补全);photo.* 载荷自 P1a 起落定。
export interface OutboxEvent<T = unknown> {
eventId: string; // cuid,持久唯一
type:
@@ -9,7 +9,10 @@ export interface OutboxEvent<T = unknown> {
| 'photo.hidden'
| 'photo.deleted'
| 'photo.likes.updated'
| 'album.updated';
| 'album.updated'
| 'category.updated'
| 'category.deleted'
| 'asset.updated';
albumId: string;
photoId?: string;
entityVersion: number; // 单调递增,P2 inbox 消费依据
@@ -30,6 +33,75 @@ export interface PhotoPublishedPayload {
entityVersion: number;
}
// album.updated 载荷:ov 副本(albums_ov)展示一张相册所需的标量 + 翻译全集。
// categories 不在此列——分类是独立事件(category.*,各自携带 entityVersion);
// banner 对象键经 asset.updated 事件同步(albums_ov.bannerAssetKey)。
export interface AlbumUpdatedPayload {
albumId: string;
publicToken: string;
status: string; // active|offline(cn 副本值)
uploadEnabled: boolean;
publicEnabled: boolean;
downloadEnabled: boolean;
originalDownloadEnabled: boolean;
defaultLocale: string;
timezone: string;
startAt: string; // ISO UTC
endAt: string | null; // ISO UTC;null 表示未定
translations: AlbumTranslationPayload[]; // locale 升序(确定性输出)
entityVersion: number;
}
export interface AlbumTranslationPayload {
locale: string;
title: string;
description: string | null;
venue: string | null;
address: string | null;
}
// category.updated 载荷:相册分类(ov 落 albums_ov.categories JSON 数组元素)。
export interface CategoryUpdatedPayload {
categoryId: string;
albumId: string;
sortOrder: number;
translations: CategoryTranslationPayload[]; // locale 升序
entityVersion: number;
}
export interface CategoryTranslationPayload {
locale: string;
name: string;
}
// category.deleted 载荷:墓碑(ov 从 albums_ov.categories 移除该元素且不被旧
// 事件复活)。删除后 cn 行已不存在,entityVersion 为删除时刻的终值。
export interface CategoryDeletedPayload {
categoryId: string;
albumId: string;
entityVersion: number;
}
// photo.hidden / photo.deleted 载荷:可见性变更最小事实(ov 副本据此置
// photos_ov.visibility;无展示字段——撤下语义只需标识与版本)。
export interface PhotoVisibilityChangedPayload {
photoId: string;
albumId: string;
visibility: string; // hidden|deleted
entityVersion: number;
}
// asset.updated 载荷:相册资产生效(state pending→effective 的迁移才发事件)。
// ov 侧 kind=banner 投影到 albums_ov.bannerAssetKey;cover/logo 仅推进版本。
export interface AssetUpdatedPayload {
assetId: string;
albumId: string;
kind: string; // banner|cover|logo
locale: string | null;
objectKey: string;
entityVersion: number;
}
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
// __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。
export const OUTBOX_EVENT_TYPES = [
@@ -38,4 +110,43 @@ export const OUTBOX_EVENT_TYPES = [
'photo.deleted',
'photo.likes.updated',
'album.updated',
'category.updated',
'category.deleted',
'asset.updated',
] as const satisfies readonly OutboxEvent['type'][];
// ── outbox 类型 → 内部同步线格式(openapi SyncEvent.entityType)的映射 ──
// 发送器(services/sync)与 cn reconcile 端点共用,单一实现防两侧漂移;
// exhaustive switch:新增事件类型漏映射会在此编译报错。
/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 为预留)。 */
export type SyncEntityType =
| 'album'
| 'category'
| 'photo'
| 'asset'
| 'slot-state';
export function syncEntityTypeOfOutboxType(
type: OutboxEvent['type'],
): SyncEntityType {
switch (type) {
case 'album.updated':
return 'album';
case 'category.updated':
case 'category.deleted':
return 'category';
case 'asset.updated':
return 'asset';
case 'photo.published':
case 'photo.hidden':
case 'photo.deleted':
case 'photo.likes.updated':
return 'photo';
}
}
/** 墓碑类事件(副本删除且不被旧事件复活,A26)。 */
export function isTombstoneOutboxType(type: OutboxEvent['type']): boolean {
return type === 'category.deleted' || type === 'photo.deleted';
}
+1 -1
View File
@@ -673,7 +673,7 @@ export interface components {
* @description 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。
* @enum {string}
*/
readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "INTERNAL_ERROR";
readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "SYNC_AUTH_FAILED" | "INTERNAL_ERROR";
/** @description 服务器生成的可读消息(诊断用,不作为客户端展示文案依据)。 */
readonly message: string;
/** @description 请求追踪 ID。 */
+1
View File
@@ -5,5 +5,6 @@
export * from './constants.js';
export * from './errors.js';
export * from './events.js';
export * from './internal-sync.js';
export * from './realtime.js';
export type * from './generated/openapi.js';
+79
View File
@@ -0,0 +1,79 @@
// packages/contracts/src/internal-sync.ts —— /internal/** 服务间同步接口的
// HMAC 鉴权单一实现(P2 Task 3)。签名规则(openapi.yaml [internal] tag 描述):
// X-Sync-Signature = hex(HMAC-SHA256(原始请求体字节, SYNC_INTERNAL_KEY))
// X-Sync-Timestamp = Unix 秒,与服务端时差 ±300s 内有效(防重放)
// 发送方(services/sync sender)与接收方(cn bootstrap/reconcile、ov
// content-batches 的 InternalSyncGuard)共用本模块,杜绝两侧规则漂移。
// 注意签名覆盖的是**原始请求体字节**(rawBody):接收端必须经 body-parser 的
// verify 钩子(NestFactory rawBody: true)在 JSON 解析前捕获原始字节,重序列
// 化的 body 与签名不一致会 401。
import { createHmac, timingSafeEqual } from 'node:crypto';
/** 内部同步 HMAC 密钥的 env 键名(仅 env 注入,不入库不入码)。 */
export const SYNC_INTERNAL_KEY_ENV = 'SYNC_INTERNAL_KEY';
/** HMAC 签名/时间戳请求头(小写;Node req.headers 已归一小写)。 */
export const SYNC_SIGNATURE_HEADER = 'x-sync-signature';
export const SYNC_TIMESTAMP_HEADER = 'x-sync-timestamp';
/** 时间戳容忍窗(秒,防重放;契约 [internal] tag 描述 ±300s)。 */
export const SYNC_TIMESTAMP_TOLERANCE_SECONDS = 300;
/**
* 计算请求体签名:hex(HMAC-SHA256(rawBody, key))。
* rawBody 必须是与线上传输完全一致的原始字节(发送侧:JSON.stringify 的产物
* 原样作为 fetch body;接收侧:body-parser verify 捕获的 Buffer)。
*/
export function signSyncBody(
key: string,
rawBody: string | Buffer,
): string {
return createHmac('sha256', key).update(rawBody).digest('hex');
}
/** 验签输入:头取值原样传入(数组/缺失由本函数判 false,不抛错)。 */
export interface VerifySyncSignatureInput {
key: string;
rawBody: Buffer | string;
timestampHeader: unknown;
signatureHeader: unknown;
/** 可注入时钟(测试用);缺省 Date.now()。 */
nowMs?: number;
}
/**
* 接收端验签:时间戳在窗内 + 签名 hex(64) 形状 + 常时比较全过才 true。
* key 未配置(空串)恒 false——密钥缺失时内部端点整体不可用而非裸奔。
*/
export function verifySyncSignature(input: VerifySyncSignatureInput): boolean {
const { key, rawBody, timestampHeader, signatureHeader } = input;
if (key === '') {
return false;
}
const timestamp =
typeof timestampHeader === 'string'
? Number(timestampHeader.trim())
: Number.NaN;
if (!Number.isFinite(timestamp)) {
return false;
}
const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1000);
if (
Math.abs(nowSeconds - timestamp) > SYNC_TIMESTAMP_TOLERANCE_SECONDS
) {
return false;
}
if (
typeof signatureHeader !== 'string' ||
!/^[0-9a-f]{64}$/.test(signatureHeader.trim())
) {
return false;
}
const expected = signSyncBody(key, rawBody);
const provided = signatureHeader.trim();
// 两串均为 64 hex 字符(上方正则保证),等长 Buffer,timingSafeEqual 安全。
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex'),
);
}
+2 -1
View File
@@ -8,7 +8,8 @@
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["src/__tests__"]
@@ -0,0 +1,10 @@
-- P2 Task 3:cn 事件补全——albums/categories/album_assets 增 entityVersion
-- (事件版本条件自增,同 photos 模式);outbox_events 增 priority
-- (normal|high:相册下线/照片隐藏删除类高优先级,发送器批次内优先投递)。
-- VARCHAR 列显式 utf8mb4_0900_bin(沿用库级约定:--create-only 后手工定校对,
-- Prisma 生成 DDL 固定 unicode_ci)。INTEGER 列无校对问题;新列全部带默认值,
-- 存量行与 P1 流程不受影响(相册/分类/资产存量行版本从 1 起算)。
ALTER TABLE `album_assets` ADD COLUMN `entityVersion` INTEGER NOT NULL DEFAULT 1;
ALTER TABLE `albums` ADD COLUMN `entityVersion` INTEGER NOT NULL DEFAULT 1;
ALTER TABLE `categories` ADD COLUMN `entityVersion` INTEGER NOT NULL DEFAULT 1;
ALTER TABLE `outbox_events` ADD COLUMN `priority` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL DEFAULT 'normal';
+8 -4
View File
@@ -66,6 +66,7 @@ model albums {
downloadEnabled Boolean @default(true)
originalDownloadEnabled Boolean @default(false)
watermarkVersion Int @default(0)
entityVersion Int @default(1) // P2 Task 3:album.updated 事件版本(同 photos 模式)
createdAt DateTime @default(now()) @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
@@ -99,10 +100,11 @@ model album_members {
}
model categories {
id String @id @default(cuid())
albumId String @db.VarChar(64)
sortOrder Int @default(0)
createdAt DateTime @default(now()) @db.DateTime(6)
id String @id @default(cuid())
albumId String @db.VarChar(64)
sortOrder Int @default(0)
entityVersion Int @default(1) // P2 Task 3:category.* 事件版本(同 photos 模式)
createdAt DateTime @default(now()) @db.DateTime(6)
@@index([albumId, sortOrder])
}
@@ -125,6 +127,7 @@ model album_assets {
state String @default("pending") @db.VarChar(16) // pending|effective(行内一次性上传会话,P1b Task 3)
expectedBytes Int @default(0) // init 申明字节数(complete head 核验依据)
contentType String @default("") @db.VarChar(64) // init 申明 MIME(complete head 核验依据)
entityVersion Int @default(1) // P2 Task 3:asset.updated 事件版本(仅 effective 迁移自增)
createdAt DateTime @default(now()) @db.DateTime(6)
@@index([albumId, kind])
@@ -198,6 +201,7 @@ model outbox_events {
albumId String @db.VarChar(64)
photoId String? @db.VarChar(64)
entityVersion Int
priority String @default("normal") @db.VarChar(16) // normal|high(P2 Task 3:下线/隐藏/删除类 high,发送器批次内优先)
payload Json
occurredAt DateTime @default(now()) @db.DateTime(6)
deliveredAt DateTime? @db.DateTime(6)