feat(worker): watermark versioning with atomic history regeneration

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 17:53:44 +08:00
parent 6723a0ce88
commit 243b2f8721
34 changed files with 3057 additions and 78 deletions
@@ -0,0 +1,121 @@
// packages/contracts/src/__tests__/watermark.test.ts —— 水印配置共享形状测试
// (P4a Task 3):normalize 填齐缺省不改已给值;parse 对脏数据的防御回退;
// buildAlbumUpdatedPayloadCore 的水印副本位(watermarkConfigVersion = 有效版本)
// 与翻译排序确定性。这三者是 api/worker 双侧共用的唯一权威,漂移即事故。
import {
normalizeWatermarkConfig,
parseStoredWatermarkConfig,
WATERMARK_DEFAULT_POSITION,
WATERMARK_DEFAULT_OPACITY,
WATERMARK_DEFAULT_SCALE_PERCENT,
WATERMARK_DEFAULT_FONT_SIZE,
WATERMARK_POSITIONS,
buildAlbumUpdatedPayloadCore,
type AlbumPayloadRow,
} from '../index.js';
describe('normalizeWatermarkConfig', () => {
it('填齐全部缺省(与 openapi WatermarkConfig 可选字段一一对应)', () => {
expect(normalizeWatermarkConfig({ enabled: true, text: { content: 'x' } })).toEqual({
enabled: true,
position: WATERMARK_DEFAULT_POSITION,
opacity: WATERMARK_DEFAULT_OPACITY,
scalePercent: WATERMARK_DEFAULT_SCALE_PERCENT,
text: { content: 'x', fontSize: WATERMARK_DEFAULT_FONT_SIZE },
});
});
it('不改写已给值;enabled=false 允许无文字(关闭语义仅回显)', () => {
expect(
normalizeWatermarkConfig({
enabled: false,
position: 'topLeft',
opacity: 80,
scalePercent: 30,
text: { content: '', fontSize: 40 },
}),
).toEqual({
enabled: false,
position: 'topLeft',
opacity: 80,
scalePercent: 30,
text: { content: '', fontSize: 40 },
});
expect(normalizeWatermarkConfig({ enabled: false })).toMatchObject({
text: { content: '' },
});
});
});
describe('parseStoredWatermarkConfig', () => {
it('round-trip:normalize 后的值原样读回', () => {
const stored = normalizeWatermarkConfig({
enabled: true,
position: 'center',
opacity: 10,
scalePercent: 50,
text: { content: '水印', fontSize: 72 },
});
expect(parseStoredWatermarkConfig(stored)).toEqual(stored);
});
it('脏数据防御:非对象 / enabled 非 boolean → null(「配置不可用」)', () => {
expect(parseStoredWatermarkConfig(null)).toBeNull();
expect(parseStoredWatermarkConfig('enabled')).toBeNull();
expect(parseStoredWatermarkConfig({ position: 'center' })).toBeNull();
expect(parseStoredWatermarkConfig({ enabled: 'yes' })).toBeNull();
});
it('缺字段回落缺省;非法 position 回落缺省(不抛错)', () => {
expect(parseStoredWatermarkConfig({ enabled: true })).toEqual(
normalizeWatermarkConfig({ enabled: true }),
);
const badPosition = parseStoredWatermarkConfig({
enabled: true,
position: 'middle',
text: { content: 'x' },
});
expect(badPosition?.position).toBe(WATERMARK_DEFAULT_POSITION);
expect(WATERMARK_POSITIONS).not.toContain('middle');
});
});
describe('buildAlbumUpdatedPayloadCore', () => {
const album: AlbumPayloadRow = {
id: 'album-1',
publicToken: 'pt',
status: 'active',
uploadEnabled: true,
publicEnabled: true,
downloadEnabled: false,
originalDownloadEnabled: false,
watermarkEnabled: true,
watermarkEffectiveVersion: 3,
defaultLocale: 'zh-CN',
timezone: 'Asia/Shanghai',
startAt: new Date('2026-11-01T10:00:00Z'),
endAt: null,
entityVersion: 7,
};
it('水印副本位 = 有效版本(不是最新创建版本);翻译 locale 升序', () => {
const payload = buildAlbumUpdatedPayloadCore(album, [
{ locale: 'en', title: 'T', description: null, venue: null, address: null },
{ locale: 'zh-CN', title: '标题', description: null, venue: null, address: null },
]);
expect(payload.watermarkEnabled).toBe(true);
expect(payload.watermarkConfigVersion).toBe(3);
expect(payload.translations.map((t) => t.locale)).toEqual(['en', 'zh-CN']);
expect(payload.entityVersion).toBe(7);
expect(payload.endAt).toBeNull();
});
it('关闭态:enabled=false 携带版本(ov 回落 browse 键的判定依据)', () => {
const payload = buildAlbumUpdatedPayloadCore(
{ ...album, watermarkEnabled: false, watermarkEffectiveVersion: 4 },
[],
);
expect(payload.watermarkEnabled).toBe(false);
expect(payload.watermarkConfigVersion).toBe(4);
});
});
+70
View File
@@ -0,0 +1,70 @@
// packages/contracts/src/album-payload.ts —— album.updated 载荷构建器(P4a Task 3
// 起为跨服务共享实现)。此前唯一实现在 api src/sync/payloads.ts;水印任务完成时
// **worker** 也要写 album.updated outbox(有效版本推进须同步 ov),为避免两份
// 实现漂移,构建器收敛到契约包:输入为结构化行形状(不依赖 Prisma 类型),
// api 的 sync/payloads.ts 包装本实现并叠加 categories 快照参数,worker 直接调用。
import type { AlbumUpdatedPayload } from './events.js';
/** 构建器所需的主行列子集(cn albums 行的结构化形状;两消费方的 Prisma 行天然满足)。 */
export interface AlbumPayloadRow {
id: string;
publicToken: string;
status: string;
uploadEnabled: boolean;
publicEnabled: boolean;
downloadEnabled: boolean;
originalDownloadEnabled: boolean;
/** 有效水印位(见 watermark.ts 版本语义;列由 P4a Task 3 迁移新增,行必含)。 */
watermarkEnabled: boolean;
watermarkEffectiveVersion: number;
defaultLocale: string;
timezone: string;
startAt: Date;
endAt: Date | null;
entityVersion: number;
}
/** 翻译行的结构化形状(cn album_translations 行满足)。 */
export interface AlbumTranslationRow {
locale: string;
title: string;
description: string | null;
venue: string | null;
address: string | null;
}
/**
* album.updated 载荷(无 categories 版):标量全集 + 翻译(locale 升序)+
* 水印副本位。与 api sync/payloads.ts 的完整版逐字节同形(categories 是其
* 独有的可选叠加参数,业务事务内事件与 worker 事件均不携带)。
*/
export function buildAlbumUpdatedPayloadCore(
album: AlbumPayloadRow,
translations: AlbumTranslationRow[],
): AlbumUpdatedPayload {
return {
albumId: album.id,
publicToken: album.publicToken,
status: album.status,
uploadEnabled: album.uploadEnabled,
publicEnabled: album.publicEnabled,
downloadEnabled: album.downloadEnabled,
originalDownloadEnabled: album.originalDownloadEnabled,
watermarkEnabled: album.watermarkEnabled,
watermarkConfigVersion: album.watermarkEffectiveVersion,
defaultLocale: album.defaultLocale,
timezone: album.timezone,
startAt: album.startAt.toISOString(),
endAt: album.endAt === null ? null : album.endAt.toISOString(),
translations: [...translations]
.sort((a, b) => (a.locale < b.locale ? -1 : 1))
.map((t) => ({
locale: t.locale,
title: t.title,
description: t.description,
venue: t.venue,
address: t.address,
})),
entityVersion: album.entityVersion,
};
}
+3
View File
@@ -4,6 +4,9 @@
/** 照片处理 BullMQ 队列名(api enqueue → worker 消费;补偿扫描 Task 21)。 */
export const QUEUE_PHOTO_PROCESS = 'photo-process';
/** 水印历史重生成 BullMQ 队列名(P4a Task 3:api watermark-jobs 入队 → worker 消费)。 */
export const QUEUE_WATERMARK = 'watermark-regen';
// ── P2 双地域同步与互动(决策初值 D07/D11,验收前可调;env 覆盖键见 .env.example)──
// 运行时可调项的默认值集中在此,服务实现(Task 3+)读 env、缺省回落本表;
// HEARTBEAT_MS 属 WS 消息契约,随 realtime.ts 导出(此处不重复定义避免重名导出)。
+4
View File
@@ -47,6 +47,10 @@ export interface AlbumUpdatedPayload {
publicEnabled: boolean;
downloadEnabled: boolean;
originalDownloadEnabled: boolean;
/** P4a A18:水印副本位(ov 模式键服务)。语义见 watermark.ts 注释——取「有效版本」而非最新创建版本。 */
watermarkEnabled: boolean;
/** ov 侧模式键 `variants/{photoId}/{watermarkConfigVersion}/watermark.jpg` 的版本段。 */
watermarkConfigVersion: number;
defaultLocale: string;
timezone: string;
startAt: string; // ISO UTC
+2
View File
@@ -7,4 +7,6 @@ export * from './errors.js';
export * from './events.js';
export * from './internal-sync.js';
export * from './realtime.js';
export * from './watermark.js';
export * from './album-payload.js';
export type * from './generated/openapi.js';
+114
View File
@@ -0,0 +1,114 @@
// packages/contracts/src/watermark.ts —— 水印配置的跨服务共享形状与缺省值
// (P4a Task 3)。三个消费方经同一模块引用,杜绝形状漂移:
// · api albums 域(POST watermark-jobs:DTO 校验 → normalize 落库);
// · worker 重生成消费者(读 watermark_configs.config Json → 本形状使用);
// · worker 发布路径(新照片按相册当前配置即产水印变体)。
//
// 版本语义(本文件是权威表述,实现侧注释引用):
// · albums.watermarkVersion = 最新「已创建」配置版本——POST 即推进,新照片
// 发布立刻套用(契约 WatermarkJobCreated.configVersion 描述:新照片即刻套用);
// · albums.watermarkEnabled + watermarkEffectiveVersion = 「有效」版本——仅当该
// 版本的历史重生成任务 done(或创建时相册无已发布照片、任务即建即 done)才
// 推进。ov 副本与公开侧的模式键/变体选择以有效版本为准:对象键
// `variants/{photoId}/{version}/watermark.jpg` 只有 done 任务保证存在,
// 用有效版本杜绝「同步了新版本号但对象尚未生成」的破图窗口(A20:
// 任务完成前旧浏览图保持可用,失败照片保留旧图)。
// · enabled=false 也是一版配置(记录关闭动作):关闭后发布的新照片无水印;
// 关闭版重生成任务 = 逐张删除 watermark 变体行(对象保留,历史可溯)。
export const WATERMARK_POSITIONS = [
'topLeft',
'topRight',
'bottomLeft',
'bottomRight',
'center',
] as const;
export type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number];
// ── 服务端缺省值(契约 WatermarkConfig「可选字段缺省用服务端默认值」的唯一权威)──
export const WATERMARK_DEFAULT_POSITION: WatermarkPosition = 'bottomRight';
export const WATERMARK_DEFAULT_OPACITY = 50;
export const WATERMARK_DEFAULT_SCALE_PERCENT = 20;
export const WATERMARK_DEFAULT_FONT_SIZE = 24;
/**
* 归一化后的存储形状(watermark_configs.config)。API 保存时经
* normalizeWatermarkConfig 填齐缺省值落库——worker 与 GET 读到的永远是完整
* 配置,不再各自解释缺省。enabled=false 时 text.content 允许为空串
* (关闭语义不绘制文字,其余字段仅作回显)。
*/
export interface StoredWatermarkConfig {
enabled: boolean;
position: WatermarkPosition;
/** 不透明度百分比 0..100。 */
opacity: number;
/** 水印层宽度相对浏览图宽度的百分比 5..50。 */
scalePercent: number;
text: {
content: string;
/** 名义字号 8..72(渲染后再按 scalePercent 缩放,见 worker 实现)。 */
fontSize: number;
};
}
/** 契约 WatermarkConfig(创建请求 / 当前配置响应)。 */
export type WatermarkConfigInput = {
enabled: boolean;
position?: WatermarkPosition;
opacity?: number;
scalePercent?: number;
text?: { content?: string; fontSize?: number };
};
/** 缺省值填齐(不改写已给值;越界值由 API DTO 层 400 拒绝,此处不重复校验)。 */
export function normalizeWatermarkConfig(
raw: WatermarkConfigInput,
): StoredWatermarkConfig {
return {
enabled: raw.enabled,
position: raw.position ?? WATERMARK_DEFAULT_POSITION,
opacity: raw.opacity ?? WATERMARK_DEFAULT_OPACITY,
scalePercent: raw.scalePercent ?? WATERMARK_DEFAULT_SCALE_PERCENT,
text: {
content: raw.text?.content ?? '',
fontSize: raw.text?.fontSize ?? WATERMARK_DEFAULT_FONT_SIZE,
},
};
}
/**
* 读取侧解析(watermark_configs.config Json → StoredWatermarkConfig):行由
* normalizeWatermarkConfig 写入、形状可信,此解析仅对脏数据防御——缺字段回
* 落缺省值,结构破坏(非对象 / enabled 非 boolean)返回 null(调用方按
* 「配置不可用」处理:重生成任务整败 / 公开侧回落无水印)。
*/
export function parseStoredWatermarkConfig(
value: unknown,
): StoredWatermarkConfig | null {
if (typeof value !== 'object' || value === null) {
return null;
}
const raw = value as Partial<WatermarkConfigInput> & {
text?: Partial<WatermarkConfigInput['text']>;
};
if (typeof raw.enabled !== 'boolean') {
return null;
}
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
const str = (v: unknown): string | undefined =>
typeof v === 'string' ? v : undefined;
return normalizeWatermarkConfig({
enabled: raw.enabled,
position:
raw.position !== undefined &&
(WATERMARK_POSITIONS as readonly string[]).includes(raw.position)
? raw.position
: undefined,
opacity: num(raw.opacity),
scalePercent: num(raw.scalePercent),
text: {
content: str(raw.text?.content) ?? '',
fontSize: num(raw.text?.fontSize),
},
});
}
@@ -0,0 +1,39 @@
-- P4a Task 3:水印版本化——albums 双版本位列(watermarkVersion 已有 P1 起,
-- 本迁移补「有效版本位」watermarkEnabled/watermarkEffectiveVersion,语义见
-- packages/contracts/src/watermark.ts)+ 配置版本账本 watermark_configs +
-- 历史重生成任务 watermark_jobs(id 兼作对外 jobId 与 BullMQ jobId)。
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,逐表改写为库级约定的
-- utf8mb4_0900_bin(albumId/id/errorCode 等字符串列的大小写敏感语义依赖它;
-- 新增两列非 VARCHAR,无校对关注)。
-- AlterTable
ALTER TABLE `albums` ADD COLUMN `watermarkEffectiveVersion` INTEGER NOT NULL DEFAULT 0,
ADD COLUMN `watermarkEnabled` BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE `watermark_configs` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`configVersion` INTEGER NOT NULL,
`config` JSON NOT NULL,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE INDEX `watermark_configs_albumId_configVersion_key`(`albumId`, `configVersion`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `watermark_jobs` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`configVersion` INTEGER NOT NULL,
`state` VARCHAR(16) NOT NULL DEFAULT 'pending',
`progress` INTEGER NOT NULL DEFAULT 0,
`total` INTEGER NOT NULL DEFAULT 0,
`failed` INTEGER NOT NULL DEFAULT 0,
`errorCode` VARCHAR(64) NULL,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updatedAt` DATETIME(6) NOT NULL,
INDEX `watermark_jobs_albumId_createdAt_idx`(`albumId`, `createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
+39
View File
@@ -65,7 +65,15 @@ model albums {
publicEnabled Boolean @default(true)
downloadEnabled Boolean @default(true)
originalDownloadEnabled Boolean @default(false)
// ── 水印版本化(P4a Task 3)── 双版本位语义(权威表述见 contracts watermark.ts):
// watermarkVersion = 最新「已创建」配置版本(POST 即推进,新照片发布即刻套用,
// 也就是契约 AlbumSummary.watermarkVersion / WatermarkJobCreated.configVersion);
// watermarkEnabled + watermarkEffectiveVersion = 「有效」版本位——仅当该版本的
// 历史重生成任务 done 才推进(worker 完成事务维护,禁用版同样推进以翻转
// enabled=false)。ov 副本与公开侧变体选择以有效版本为准(对象存在性保证)。
watermarkVersion Int @default(0)
watermarkEnabled Boolean @default(false)
watermarkEffectiveVersion Int @default(0)
entityVersion Int @default(1) // P2 Task 3:album.updated 事件版本(同 photos 模式)
// P2 Task 9 统计回传副本位(ov→cn):viewCountReplica 为 ov album_stats
// 权威值的替换式副本;statsVersion 是回传替换版本(仅更高 statVersion 替换,
@@ -205,6 +213,37 @@ model photo_jobs {
@@unique([photoId, type])
@@index([state, nextRetryAt])
}
// —— 水印版本化(P4a Task 3)——
// watermark_configs:配置版本账本(append-only,每 POST 一行,config 为归一化
// StoredWatermarkConfig——contracts watermark.ts 的形状,缺省已填齐);
// watermark_jobs:历史重生成任务(id 兼作对外 jobId 与 BullMQ jobId——cuid 无
// ':',满足 BullMQ 保留分隔符约束)。state 语义:pending=已入队待消费;
// running=消费中(progress/failed 随照片逐张推进);done=全部处理完成(允许
// failed>0 的部分成功——失败张数记录在案,重试=重新 POST 新任务,按照片粒度
// 幂等);failed=整任务中止(配置缺失等任务级错误,errorCode 必有)。
model watermark_configs {
id String @id @default(cuid())
albumId String @db.VarChar(64)
configVersion Int
config Json
createdAt DateTime @default(now()) @db.DateTime(6)
@@unique([albumId, configVersion])
}
model watermark_jobs {
id String @id @default(cuid())
albumId String @db.VarChar(64)
configVersion Int
state String @default("pending") @db.VarChar(16) // pending|running|done|failed
progress Int @default(0)
total Int @default(0)
failed Int @default(0)
errorCode String? @db.VarChar(64)
createdAt DateTime @default(now()) @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
@@index([albumId, createdAt])
}
model outbox_events {
id String @id @default(cuid())
type String @db.VarChar(64)
@@ -0,0 +1,8 @@
-- P4a Task 3:albums_ov 水印副本位(album.updated 载荷投影,语义见
-- packages/contracts/src/watermark.ts)——watermarkEnabled=true 时 ov 公开侧
-- 浏览键切换为模式键 `variants/{photoId}/{watermarkConfigVersion}/watermark.jpg`
-- (确定性推导,无逐照片同步;对象存在性由 cn 侧「有效版本」保证)。
-- 两列均非 VARCHAR,无校对手决关注(沿用库级 utf8mb4_0900_bin 表默认)。
-- AlterTable
ALTER TABLE `albums_ov` ADD COLUMN `watermarkConfigVersion` INTEGER NOT NULL DEFAULT 0,
ADD COLUMN `watermarkEnabled` BOOLEAN NOT NULL DEFAULT false;
+6
View File
@@ -38,6 +38,12 @@ model albums_ov {
publicEnabled Boolean @default(true)
downloadEnabled Boolean @default(true)
originalDownloadEnabled Boolean @default(false)
// P4a Task 3 水印副本位(album.updated 载荷投影;语义见 contracts watermark.ts):
// 有效版本(存在 done 重生成任务)——watermarkEnabled=true 时 ov 公开侧浏览键
// 用模式键 `variants/{photoId}/{watermarkConfigVersion}/watermark.jpg`(确定性
// 推导,无逐照片同步;对象存在性由有效版本保证)。
watermarkEnabled Boolean @default(false)
watermarkConfigVersion Int @default(0)
emergencyDisabled Boolean @default(false)
translations Json // [{locale,title,description,venue,address}](cn 原文数组)
categories Json // [{id,sortOrder,translations:[{locale,name}]}]
+6
View File
@@ -9,6 +9,12 @@ export { MinioStorageAdapter } from './minio.adapter';
export type { MinioStorageConfig } from './minio.adapter';
export { StorageError, ErrorCodes } from './errors';
export type { StoragePort, UploadCredential } from './port';
export {
OBJECT_KEY_ROOT,
variantsObjectKey,
watermarkVariantObjectKey,
} from './keys';
export type { PhotoVariantName } from './keys';
/**
* 从 S3_* 环境变量构造 MinIO/S3 兼容适配器(仓库根 .env 经 dotenv-cli 或
+35
View File
@@ -0,0 +1,35 @@
// packages/storage/src/keys.ts —— 对象键布局的跨服务权威段(P4a Task 3 起)。
// 此前 OBJECT_KEY_ROOT 在 api uploads/support.ts 与 worker publish.service.ts
// 各持一份字面量;水印引入第三处消费方(ov 公开侧模式键推导,无任何 DB 行可
// 依赖),收敛到存储包单一出处,杜绝多环境前缀化时(P5)三处漂移。
// P1a 单环境(env=dev)硬编码;多环境前缀化属 P5 部署事项。
/** 对象键根(与各 test/reset.ts 的 MinIO 清理前缀一致)。 */
export const OBJECT_KEY_ROOT = 'photo-live/dev';
/** photo_variants 变体名(与 db-cn photo_variants.variant 取值一致)。 */
export type PhotoVariantName = 'thumb' | 'browse' | 'watermark';
/** 变体对象键(worker 写入位 / ov 模式键推导位,同一实现)。 */
export function variantsObjectKey(
albumId: string,
photoId: string,
version: number,
variant: PhotoVariantName,
): string {
return `${OBJECT_KEY_ROOT}/albums/${albumId}/variants/${photoId}/${version}/${variant}.jpg`;
}
/**
* 水印变体键的便捷推导(P4a Task 3 ov 侧核心约定):键由 (albumId, photoId,
* configVersion) 确定性构成——ov 副本不逐照片同步变体键,公开侧按相册有效
* watermarkConfigVersion 直接推导(对象存在性由 cn 侧有效版本语义保证,见
* packages/contracts/src/watermark.ts)。
*/
export function watermarkVariantObjectKey(
albumId: string,
photoId: string,
configVersion: number,
): string {
return variantsObjectKey(albumId, photoId, configVersion, 'watermark');
}