// 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 & { text?: Partial; }; 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), }, }); }