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
+110 -11
View File
@@ -13,12 +13,18 @@
import { Readable } from 'node:stream';
import type { Prisma, PrismaClient } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import type { PhotoPublishedPayload } from '@photo/contracts';
import {
variantsObjectKey,
watermarkVariantObjectKey,
} from '@photo/storage';
import {
parseStoredWatermarkConfig,
type PhotoPublishedPayload,
type StoredWatermarkConfig,
} from '@photo/contracts';
import type { Logger } from 'pino';
import { runPipeline } from './pipeline';
/** 对象键根(与 api src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致:P1a 单环境)。 */
const OBJECT_KEY_ROOT = 'photo-live/dev';
import { composeWatermarkBrowse, type WatermarkComposeResult } from './watermark';
/** 变体键版本段(对应 photo_variants.version;水印/规格升级时递增)。 */
const VARIANT_VERSION = 1;
@@ -31,13 +37,11 @@ export type ProcessOutcome =
| 'already' // 重复投递:已 ready(或并发赢家已处理),幂等 no-op
| 'skipped'; // 照片不存在 / 状态不可处理(pending_upload、processing 等)
export function variantsObjectKey(
albumId: string,
photoId: string,
version: number,
variant: 'thumb' | 'browse',
): string {
return `${OBJECT_KEY_ROOT}/albums/${albumId}/variants/${photoId}/${version}/${variant}.jpg`;
/** 发布路径的水印产出现状(P4a Task 3,见 resolveWatermarkTargets 注释)。 */
interface PublishWatermarkTarget {
version: number;
config: StoredWatermarkConfig;
out: WatermarkComposeResult;
}
export class PublishService {
@@ -86,6 +90,15 @@ export class PublishService {
await this.storage.put(thumbKey, pipeline.thumb, 'image/jpeg');
await this.storage.put(browseKey, pipeline.browse, 'image/jpeg');
// P4a Task 3:发布路径水印变体(「新照片默认套用最新版本」的落点)。
// 对象先于事务写(键含版本段、内容确定,重复写无害),行 upsert 在发布
// 事务内与 thumb/browse 同生共死。
const watermarkTargets = await this.produceWatermarkVariants(
photo.albumId,
photoId,
input,
);
const publishedAt = new Date();
const outcome = await this.prisma.$transaction(async (tx) => {
// 条件更新 = 处理权抢占:WHERE 仍处可处理态。匹配 0 → 已被处理,幂等返回。
@@ -146,6 +159,29 @@ export class PublishService {
height: pipeline.browseHeight,
},
});
// 水印变体行(targets 已按版本升序,末次 upsert 后行版本 = 最大写入版本)。
for (const target of watermarkTargets) {
const key = watermarkVariantObjectKey(photo.albumId, photoId, target.version);
await tx.photo_variants.upsert({
where: { photoId_variant: { photoId, variant: 'watermark' } },
update: {
objectKey: key,
bytes: target.out.buffer.length,
version: target.version,
width: target.out.width,
height: target.out.height,
},
create: {
photoId,
variant: 'watermark',
objectKey: key,
bytes: target.out.buffer.length,
version: target.version,
width: target.out.width,
height: target.out.height,
},
});
}
const payload: PhotoPublishedPayload = {
photoId,
albumId: photo.albumId,
@@ -184,4 +220,67 @@ export class PublishService {
}
return Buffer.concat(chunks);
}
/**
* 发布路径的水印变体产出现状(P4a Task 3)。目标版本集合的推导(见
* contracts watermark.ts 版本语义):
* · 最新配置(albums.watermarkVersion)开启 → 写最新版键;
* · 相册「有效版本」(watermarkEffectiveVersion,ov 侧仍在服务的旧版)与
* 最新版不同且开启 → **双写**:重生成窗口内发布的新照片同时落有效版键,
* 杜绝 ov 模式键指向尚未生成的对象(破图窗口);cn 侧行版本取最大值;
* · 最新配置关闭但有效配置仍开启(关闭版重生成进行中)→ 写有效版键:
* cn 公开侧跟有效位(watermarkEnabled 仍 true)继续服务旧水印行,直至
* 关闭任务逐张删行渐进回落 browse;ov 侧模式键在切换前继续有对象可服务
* (窗口内两地域一致展示旧图——A20 原子切换语义)。
* 两配置一致时仅合成一次(同 config 结果缓存)。
*/
private async produceWatermarkVariants(
albumId: string,
photoId: string,
input: Buffer,
): Promise<PublishWatermarkTarget[]> {
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: {
watermarkVersion: true,
watermarkEnabled: true,
watermarkEffectiveVersion: true,
},
});
if (album === null) {
return [];
}
const versions = new Set<number>();
if (album.watermarkVersion > 0) {
versions.add(album.watermarkVersion);
}
if (album.watermarkEnabled && album.watermarkEffectiveVersion > 0) {
versions.add(album.watermarkEffectiveVersion);
}
if (versions.size === 0) {
return [];
}
const configRows = await this.prisma.watermark_configs.findMany({
where: { albumId, configVersion: { in: [...versions] } },
});
const byVersion = new Map<number, StoredWatermarkConfig>();
for (const row of configRows) {
const parsed = parseStoredWatermarkConfig(row.config);
if (parsed !== null) {
byVersion.set(row.configVersion, parsed);
}
}
const targets: PublishWatermarkTarget[] = [];
for (const version of [...versions].sort((a, b) => a - b)) {
const config = byVersion.get(version);
if (config === undefined || !config.enabled) {
continue;
}
const out = await composeWatermarkBrowse(input, config);
const key = watermarkVariantObjectKey(albumId, photoId, version);
await this.storage.put(key, out.buffer, 'image/jpeg');
targets.push({ version, config, out });
}
return targets;
}
}