feat(worker): watermark versioning with atomic history regeneration
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user