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),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
+8
@@ -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;
|
||||
@@ -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}]}]
|
||||
|
||||
@@ -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 或
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user