Files
photo-live/services/worker/src/watermark.service.ts
T

315 lines
13 KiB
TypeScript
Raw Normal View History

// services/worker/src/watermark.service.ts —— 水印历史重生成服务(P4a Task 3,
// 「显式任务」语义的实现体:水印变更默认仅影响新照片,历史照片经本任务重derive)。
// 逐张处理(分批 50):原图 → composeWatermarkBrowse(浏览图基线 + 文字层)→
// 写版本化水印键 `variants/{photoId}/{configVersion}/watermark.jpg` → upsert
// photo_variants(variant='watermark', version=configVersion)。原子性口径(A20):
// · 对象先写、行后 upsert——cn 公开侧的选图规则是「有效开关开启且行存在 ⇒
// 用行的键」(行只会指向已写成功的对象),处理中的照片始终有 browse 变体
// 兜底,绝无破图;
// · 原图(originals)与 browse 变体零触碰(e2e A20 断言);
// · 照片级失败(原图缺失/解码失败)计入 failed 并继续——部分成功允许,
// job 仍 done(failed>0 供前端展示;重试 = 重新 POST 新任务,按照片粒度
// 幂等:同 configVersion+photo 重复 upsert 天然收敛)。失败张**旧图保留**:
// 行不动(cn 继续服务旧键)+ 旧图对象拷贝到目标版本键(ov 模式键在有效
// 版本推进后仍有对象可服务——失败照片保留旧图,A20;见 fallbackKeepOldImage);
// · 任务级中止(配置缺失/不可解析)→ state failed + errorCode,照片零处理
// (有效版本不推进,ov/cn 继续旧配置,杜绝半切换状态)。
// 关闭版(enabled=false)重生成 = 逐张删除 watermark 变体行(对象保留供历史
// 版本回溯,成本可忽略);完成后有效位 enabled=false,两侧公开服务回落 browse。
// 完成事务:job 置 done + 推进 albums「有效版本位」(watermarkEffectiveVersion
// /watermarkEnabled,仅更高版本推进——乱序完成的旧任务自然让位)+ entityVersion+1
// + outbox album.updated(载荷经 contracts 共享构建器,ov 副本据此切换模式键)。
import { Readable } from 'node:stream';
import type { Prisma, PrismaClient } from '@photo/db-cn';
import {
buildAlbumUpdatedPayloadCore,
parseStoredWatermarkConfig,
type StoredWatermarkConfig,
} from '@photo/contracts';
import { watermarkVariantObjectKey, type StoragePort } from '@photo/storage';
import type { Logger } from 'pino';
import { composeWatermarkBrowse } from './watermark';
/** 照片分批大小(逐批查询,游标推进;发布中途的新照片自然纳入后续批次)。 */
const PHOTO_BATCH_SIZE = 50;
/** 任务级错误码(照片级失败不使用——只计数)。 */
export const WATERMARK_ERROR_CONFIG_MISSING = 'WATERMARK_CONFIG_MISSING';
export const WATERMARK_ERROR_CONFIG_INVALID = 'WATERMARK_CONFIG_INVALID';
export const WATERMARK_ERROR_ALBUM_MISSING = 'WATERMARK_ALBUM_MISSING';
export type WatermarkJobOutcome =
| 'done' // 全部处理完成(允许 failed>0 部分成功)
| 'failed' // 任务级中止(errorCode 已落行)
| 'skipped'; // 行不存在 / 已 done 的重复投递
export class WatermarkRegenService {
constructor(
private readonly prisma: PrismaClient,
private readonly storage: StoragePort,
private readonly logger?: Logger,
) {}
/** 处理一个重生成任务(BullMQ 投递与 e2e 直驱共用入口)。 */
async processJob(jobRowId: string): Promise<WatermarkJobOutcome> {
const job = await this.prisma.watermark_jobs.findUnique({
where: { id: jobRowId },
});
if (job === null || job.state === 'done') {
return 'skipped';
}
// 认领(含 BullMQ 整任务重试对 failed 行的复活)+ 计数清零重跑(按照片
// 幂等,重复处理无副作用)。
const claimed = await this.prisma.watermark_jobs.updateMany({
where: { id: jobRowId, state: { in: ['pending', 'running', 'failed'] } },
data: { state: 'running', progress: 0, failed: 0, errorCode: null },
});
if (claimed.count === 0) {
return 'skipped';
}
const configRow = await this.prisma.watermark_configs.findUnique({
where: {
albumId_configVersion: {
albumId: job.albumId,
configVersion: job.configVersion,
},
},
});
const config = parseStoredWatermarkConfig(configRow?.config);
if (configRow === null) {
return this.abortJob(jobRowId, WATERMARK_ERROR_CONFIG_MISSING);
}
if (config === null) {
return this.abortJob(jobRowId, WATERMARK_ERROR_CONFIG_INVALID);
}
const albumExists = await this.prisma.albums.findUnique({
where: { id: job.albumId },
select: { id: true },
});
if (albumExists === null) {
return this.abortJob(jobRowId, WATERMARK_ERROR_ALBUM_MISSING);
}
let processed = 0;
let failed = 0;
let cursor: string | undefined;
for (;;) {
const photos = await this.prisma.photos.findMany({
where: {
albumId: job.albumId,
visibility: 'published',
...(cursor === undefined ? {} : { id: { gt: cursor } }),
},
orderBy: { id: 'asc' },
take: PHOTO_BATCH_SIZE,
select: { id: true, objectKey: true },
});
if (photos.length === 0) {
break;
}
for (const photo of photos) {
try {
await this.processPhoto(photo.id, photo.objectKey, job.albumId, job.configVersion, config);
} catch (err) {
failed += 1;
this.logger?.warn(
{ jobId: jobRowId, photoId: photo.id, err: err instanceof Error ? err.message : String(err) },
'水印重生成单张失败(计入 failed,继续)',
);
if (config.enabled) {
// A20「失败照片保留旧图」的 ov 侧保障:旧图对象拷贝到目标版本键,
// 使 done 后 ov 模式键(有效版本)仍有对象可服务。best-effort——
// 拷贝再失败(存储整体故障)只影响 ov 该张,cn 旧行照常服务。
try {
await this.fallbackKeepOldImage(photo.id, job.albumId, job.configVersion);
} catch (fallbackErr) {
this.logger?.warn(
{
jobId: jobRowId,
photoId: photo.id,
err: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
},
'水印重生成失败张的旧图拷贝亦失败(ov 该张模式键将缺对象)',
);
}
}
}
processed += 1;
cursor = photo.id;
await this.prisma.watermark_jobs.update({
where: { id: jobRowId },
data: { progress: processed, failed },
});
}
}
await this.completeJob(jobRowId, job.albumId, job.configVersion, config);
this.logger?.info(
{ jobId: jobRowId, albumId: job.albumId, configVersion: job.configVersion, total: processed, failed },
'水印重生成任务完成',
);
return 'done';
}
/**
* 单张:enabled → 产水印变体(对象先写、行后 upsert);disabled → 删行
* (对象保留)。抛错由调用方计数继续。
*/
private async processPhoto(
photoId: string,
objectKey: string,
albumId: string,
configVersion: number,
config: StoredWatermarkConfig,
): Promise<void> {
if (!config.enabled) {
await this.prisma.photo_variants.deleteMany({
where: { photoId, variant: 'watermark' },
});
return;
}
const input = await this.readOriginal(objectKey);
const out = await composeWatermarkBrowse(input, config);
const key = watermarkVariantObjectKey(albumId, photoId, configVersion);
await this.storage.put(key, out.buffer, 'image/jpeg');
await this.prisma.photo_variants.upsert({
where: { photoId_variant: { photoId, variant: 'watermark' } },
update: {
objectKey: key,
bytes: out.buffer.length,
version: configVersion,
width: out.width,
height: out.height,
},
create: {
photoId,
variant: 'watermark',
objectKey: key,
bytes: out.buffer.length,
version: configVersion,
width: out.width,
height: out.height,
},
});
}
/**
* 失败张旧图保留(A20,对象级兜底——行不动):把照片当前实际展示的旧图
* (旧水印行对象;无水印行则 browse 对象)拷贝到目标版本水印键。done 后
* ov 模式键指向有效版本,此拷贝保证该键有对象(旧图字节);cn 侧不受影响
* (旧行未覆盖,继续服务旧键)。源=目标(同版本重跑)时跳过。
*/
private async fallbackKeepOldImage(
photoId: string,
albumId: string,
configVersion: number,
): Promise<void> {
const variants = await this.prisma.photo_variants.findMany({
where: { photoId, variant: { in: ['watermark', 'browse'] } },
});
const source =
variants.find((v) => v.variant === 'watermark') ??
variants.find((v) => v.variant === 'browse');
if (source === undefined) {
return; // 连 browse 行都缺(post-publish 不变量破坏):无可拷贝,放弃。
}
const targetKey = watermarkVariantObjectKey(albumId, photoId, configVersion);
if (source.objectKey === targetKey) {
return;
}
const bytes = await this.readOriginal(source.objectKey);
await this.storage.put(targetKey, bytes, 'image/jpeg');
}
/** 任务级中止:照片零处理,行置 failed + errorCode。 */
private async abortJob(
jobRowId: string,
errorCode: string,
): Promise<WatermarkJobOutcome> {
await this.prisma.watermark_jobs.update({
where: { id: jobRowId },
data: { state: 'failed', errorCode },
});
this.logger?.error({ jobId: jobRowId, errorCode }, '水印重生成任务中止');
return 'failed';
}
/**
* 完成事务:job done + 有效版本推进 + album.updated outbox。乐观重试同
* albums.service.update(并发相册更新败方整事务重跑;3 次仍败抛错交还
* BullMQ 重试——照片处理幂等,完成事务随重投递收敛)。乱序完成的旧任务
* (有效版本已更高)跳过推进、只置 done。
*/
private async completeJob(
jobRowId: string,
albumId: string,
configVersion: number,
config: StoredWatermarkConfig,
): Promise<void> {
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
await tx.watermark_jobs.updateMany({
where: { id: jobRowId, state: 'running' },
data: { state: 'done', errorCode: null },
});
const album = await tx.albums.findUnique({
where: { id: albumId },
select: { entityVersion: true, watermarkEffectiveVersion: true },
});
if (album === null || album.watermarkEffectiveVersion >= configVersion) {
return 'done' as const;
}
const claimed = await tx.albums.updateMany({
where: {
id: albumId,
entityVersion: album.entityVersion,
watermarkEffectiveVersion: album.watermarkEffectiveVersion,
},
data: {
watermarkEffectiveVersion: configVersion,
watermarkEnabled: config.enabled,
entityVersion: { increment: 1 },
},
});
if (claimed.count === 0) {
return 'retry' as const;
}
const updated = await tx.albums.findUniqueOrThrow({
where: { id: albumId },
});
const translations = await tx.album_translations.findMany({
where: { albumId },
orderBy: { locale: 'asc' },
});
await tx.outbox_events.create({
data: {
type: 'album.updated',
albumId,
entityVersion: updated.entityVersion,
payload: buildAlbumUpdatedPayloadCore(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
return 'done' as const;
});
}
if (outcome !== 'done') {
throw new Error(`watermark: 完成事务乐观重试耗尽 jobId=${jobRowId}`);
}
}
/** 流式拉取原图并聚合为单缓冲(同 publish.service.readOriginal 约定)。 */
private async readOriginal(objectKey: string): Promise<Buffer> {
const stream = (await this.storage.getStream(objectKey)) as Readable;
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
return Buffer.concat(chunks);
}
}