feat(worker): watermark versioning with atomic history regeneration
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 导出(此处不重复定义避免重名导出)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user