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
+10 -2
View File
@@ -1,7 +1,9 @@
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15
// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传,
// P4a Task 2 增补 admin 交接端点)。PermissionsService(A02 权限基石)+
// AlbumsService/MembersService/CategoriesService/AssetsService 与对应控制器。
// P4a Task 2 增补 admin 交接端点,P4a Task 3 增补水印配置/重生成端点)。
// PermissionsService(A02 权限基石)+
// AlbumsService/MembersService/CategoriesService/AssetsService/
// WatermarkService 与对应控制器。
// export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一
// 份权限判定逻辑;AlbumsService 等暂无外部消费者不 export。
// StoragePort 装配说明(P1b Task 3):UploadsModule 为 PermissionsService 反向
@@ -18,6 +20,9 @@ import { CategoriesService } from './categories.service';
import { MembersController } from './members.controller';
import { MembersService } from './members.service';
import { PermissionsService } from './permissions.service';
import { WatermarkController } from './watermark.controller';
import { WatermarkService } from './watermark.service';
import { watermarkQueueProvider } from './watermark-queue.provider';
import { storageProvider } from '../uploads/storage.provider';
@Module({
@@ -27,6 +32,7 @@ import { storageProvider } from '../uploads/storage.provider';
CategoriesController,
AssetsController,
AdminAlbumsController,
WatermarkController,
],
providers: [
PermissionsService,
@@ -34,6 +40,8 @@ import { storageProvider } from '../uploads/storage.provider';
MembersService,
CategoriesService,
AssetsService,
WatermarkService,
watermarkQueueProvider,
storageProvider,
],
exports: [PermissionsService],
@@ -173,6 +173,8 @@ export class AlbumsService {
publicEnabled: true,
downloadEnabled: true,
originalDownloadEnabled: true,
watermarkEnabled: true,
watermarkEffectiveVersion: true,
defaultLocale: true,
timezone: true,
startAt: true,
+77
View File
@@ -317,6 +317,83 @@ export class AlbumTransferRequestDto {
keepAsCollaborator!: boolean;
}
// ── 水印(P4a Task 3)──
/** 契约 WatermarkPosition 枚举(与 contracts WATERMARK_POSITIONS 同值)。 */
export const WATERMARK_POSITION_VALUES = [
'topLeft',
'topRight',
'bottomLeft',
'bottomRight',
'center',
] as const;
export type WatermarkPositionValue = (typeof WATERMARK_POSITION_VALUES)[number];
/** 契约 WatermarkTextConfig:content 1..64;fontSize 8..72。 */
class WatermarkTextConfigDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(64)
content?: string;
@IsOptional()
@IsInt()
@Min(8)
@Max(72)
fontSize?: number;
}
/**
* enabled=true 时 text.content 必填(技术方案 7.2:P4a 文字水印——无内容的
* 开启配置在 API 层即 400,worker 不再解释「开启但无字」的形态)。约束挂在
* enabled 上(text 缺席时其字段级装饰器不运行,挂 text 无法拒绝缺省)。
*/
@ValidatorConstraint({ name: 'watermarkTextRequired', async: false })
class WatermarkTextRequiredConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
if (value !== true) {
return true; // 关闭配置无文字要求。
}
const { text } = args.object as { text?: { content?: unknown } };
return (
typeof text?.content === 'string' && (text.content as string).length > 0
);
}
defaultMessage(): string {
return 'enabled=true 时必须提供 text.content(文字水印内容)';
}
}
/** 契约 WatermarkConfig(创建请求;数值边界对齐契约 min/max)。 */
export class WatermarkConfigDto {
@IsBoolean()
@Validate(WatermarkTextRequiredConstraint)
enabled!: boolean;
@IsOptional()
@IsIn(WATERMARK_POSITION_VALUES)
position?: WatermarkPositionValue;
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
opacity?: number;
@IsOptional()
@IsInt()
@Min(5)
@Max(50)
scalePercent?: number;
@IsOptional()
@ValidateNested()
@Type(() => WatermarkTextConfigDto)
text?: WatermarkTextConfigDto;
}
// ── 资产(P1b Task 3)──
/** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */
@@ -0,0 +1,86 @@
// services/api/src/albums/watermark-queue.provider.ts —— watermark-regen 队列
// 生产者(P4a Task 3)。与 uploads/queue.provider.ts 同一 lazy-safe 约定:
// · Queue 实例(含 ioredis 连接)首次 enqueue 时才创建——Redis 不可用时
// 应用启动/请求管线不受影响;
// · 入队失败只记日志不抛错:watermark_jobs(pending) 行是持久事实源。与
// photo-process 不同,本队列暂无补偿扫描(sweep 只认领 photo_jobs)——
// 孤儿 pending 行的兜底 = 重新 POST 配置(新任务按照片粒度幂等重跑,
// 契约「failed 可重新发起」同语义);补偿扫描并入 sweep 登记 P4b。
// · ioredis 'error' 挂 no-op 监听(未监听的 error 事件崩进程)。
// jobId 即 watermark_jobs.id(cuid 无 ':',满足 BullMQ 保留分隔符约束)。
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { QUEUE_WATERMARK } from '@photo/contracts';
/** DI token。 */
export const WATERMARK_QUEUE = Symbol('WATERMARK_QUEUE');
/** 生产者窄接口:服务只依赖 enqueue,便于测试替身。 */
export interface WatermarkQueueProducer {
enqueueWatermarkRegen(jobId: string): Promise<void>;
}
/** BullMQ job 名称(与 worker 消费者同值)。 */
const WATERMARK_REGEN_JOB_NAME = 'regen';
@Injectable()
export class BullMqWatermarkQueue
implements WatermarkQueueProducer, OnModuleDestroy
{
private readonly logger = new Logger(BullMqWatermarkQueue.name);
private queue: Queue | null = null;
private connection: IORedis | null = null;
private getQueue(): Queue {
if (this.queue === null) {
const url = process.env.REDIS_URL_CN ?? 'redis://localhost:6379';
this.connection = new IORedis(url, {
maxRetriesPerRequest: null, // BullMQ 阻塞命令要求
});
this.connection.on('error', (err: Error) => {
this.logger.warn(
`watermark-regen 队列连接异常(将由 ioredis 自动重连): ${err.message}`,
);
});
this.queue = new Queue(QUEUE_WATERMARK, { connection: this.connection });
}
return this.queue;
}
async enqueueWatermarkRegen(jobId: string): Promise<void> {
try {
await this.getQueue().add(
WATERMARK_REGEN_JOB_NAME,
{ jobId },
{
jobId, // 行 id 即 BullMQ jobId:重复入队天然去重。
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
},
);
} catch (err) {
this.logger.error(
`watermark-regen 入队失败(重新 POST 可重跑)jobId=${jobId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onModuleDestroy(): Promise<void> {
if (this.queue !== null) {
await this.queue.close().catch(() => undefined);
this.queue = null;
}
if (this.connection !== null) {
await this.connection.quit().catch(() => undefined);
this.connection = null;
}
}
}
export const watermarkQueueProvider = {
provide: WATERMARK_QUEUE,
useClass: BullMqWatermarkQueue,
};
@@ -0,0 +1,52 @@
// services/api/src/albums/watermark.controller.ts —— /albums/{albumId}/
// watermark-jobs 端点(P4a Task 3)。方法名与契约 operationId 一一对应
// (createWatermarkJob/getWatermarkJobs)。控制器级 SessionGuard:两端点全需
// 认证。控制器只做认证用户与路径参数透传;权限与 HTTP 映射由 WatermarkService
// 抛 ApiException(404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400
// VALIDATION_FAILED)。POST 202:任务异步受理(A20 原子切换——完成前旧浏览图
// 保持可用),进度经 GET 轮询。
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { userOf } from './albums.controller';
import { WatermarkConfigDto } from './dto';
import {
WatermarkService,
type WatermarkJobCreatedResponse,
type WatermarkJobsStatusResponse,
} from './watermark.service';
@Controller('albums/:albumId/watermark-jobs')
@UseGuards(SessionGuard)
export class WatermarkController {
constructor(private readonly watermarkService: WatermarkService) {}
/** 契约 202:配置已保存、任务已受理。 */
@Post()
@HttpCode(HttpStatus.ACCEPTED)
createWatermarkJob(
@Param('albumId') albumId: string,
@Body() dto: WatermarkConfigDto,
@Req() req: AuthenticatedRequest,
): Promise<WatermarkJobCreatedResponse> {
return this.watermarkService.create(userOf(req), albumId, dto);
}
/** 当前配置 + 历史任务(创建时间倒序)。 */
@Get()
getWatermarkJobs(
@Param('albumId') albumId: string,
@Req() req: AuthenticatedRequest,
): Promise<WatermarkJobsStatusResponse> {
return this.watermarkService.getStatus(userOf(req), albumId);
}
}
@@ -0,0 +1,258 @@
// services/api/src/albums/watermark.service.ts —— 水印配置与历史重生成任务
// 端点逻辑(P4a Task 3,A18/A20)。契约权威:openapi /albums/{albumId}/
// watermark-jobs(createWatermarkJob/getWatermarkJobs)。权限:canViewAlbum
// (404 防枚举)→ canManageAlbum(仅创建者/admin,403)——同相册 PATCH 语义。
//
// 版本语义权威表述在 packages/contracts/src/watermark.ts(此处摘要):
// · albums.watermarkVersion = 最新「已创建」配置版本——POST 即推进,新照片
// 发布立刻套用(WatermarkJobCreated.configVersion 描述);
// · albums.watermarkEnabled + watermarkEffectiveVersion = 「有效」版本——仅当
// 该版本任务 done 才由 worker 完成事务推进(0 张照片的退化任务在创建事务
// 内即建即 done、当场推进)。ov 副本与公开侧变体选择以有效版本为准。
//
// create 事务(乐观重试同 albums.service.update:entityVersion 条件 updateMany
// 为串行化点,先占位再写从属行,败方整事务重跑、版本号按新读值重算无空洞):
// ① 占位:albums.watermarkVersion 推进 + entityVersion+1(autoDone 时一并
// 推进有效版本位);
// ② watermark_configs 追加新版本(config 经 normalizeWatermarkConfig 填齐
// 缺省——worker/GET 读到的永远是完整形状);
// ③ watermark_jobs 建行(total = 当前已发布照片数;0 张 → 即建即 done);
// ④ outbox album.updated(载荷水印位为推进后的有效版本——ov 只在对象存在性
// 有保证后看到新版本号;常规优先级:水印变化不影响公开可见性);
// ⑤ audit album.watermark {configVersion, enabled, jobId}。
// 提交后入队 QUEUE_WATERMARK(失败不回滚:job 行是事实源,见
// watermark-queue.provider;autoDone 无照片可处理、无需入队)。
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import {
ErrorCodes,
normalizeWatermarkConfig,
parseStoredWatermarkConfig,
WATERMARK_DEFAULT_FONT_SIZE,
WATERMARK_DEFAULT_OPACITY,
WATERMARK_DEFAULT_POSITION,
WATERMARK_DEFAULT_SCALE_PERCENT,
} from '@photo/contracts';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import { ApiException } from '../api-exception';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import { buildAlbumUpdatedPayload } from '../sync/payloads';
import type { WatermarkConfigDto } from './dto';
import { PermissionsService } from './permissions.service';
import { albumForbidden, albumNotFound } from './support';
import {
WATERMARK_QUEUE,
type WatermarkQueueProducer,
} from './watermark-queue.provider';
export type WatermarkJobCreatedResponse =
components['schemas']['WatermarkJobCreated'];
export type WatermarkJobsStatusResponse =
components['schemas']['WatermarkJobsStatus'];
/** GET 任务列表上限(契约「历史任务(创建时间倒序)」,前端轮询场景足够)。 */
export const WATERMARK_JOBS_LIST_LIMIT = 50;
/** 乐观并发重试上限(同 albums.service.update)。 */
const OPTIMISTIC_ATTEMPTS = 3;
@Injectable()
export class WatermarkService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(WATERMARK_QUEUE)
private readonly queue: WatermarkQueueProducer,
) {}
/**
* 保存新配置版本并发起历史重生成(202:异步受理,进度经 GET 查询)。
*/
async create(
user: AuthUser,
albumId: string,
dto: WatermarkConfigDto,
): Promise<WatermarkJobCreatedResponse> {
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
if (!(await this.permissions.canManageAlbum(user, albumId))) {
throw albumForbidden('仅创建者或管理员可管理水印');
}
const config = normalizeWatermarkConfig(dto);
let jobId = '';
let configVersion = 0;
let needsWorker = false;
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < OPTIMISTIC_ATTEMPTS && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
// admin 视角 canView 恒真,删除竞态兜底同码 404。
throw albumNotFound();
}
const nextVersion = album.watermarkVersion + 1;
const total = await tx.photos.count({
where: { albumId, visibility: 'published' },
});
// 0 张已发布照片:无历史可重生成,任务即建即 done、有效版本当场推进
// (关闭版同样——「空相册关水印」立即生效)。
const autoDone = total === 0;
const claimed = await tx.albums.updateMany({
where: { id: albumId, entityVersion: album.entityVersion },
data: {
watermarkVersion: nextVersion,
entityVersion: { increment: 1 },
...(autoDone
? {
watermarkEffectiveVersion: nextVersion,
watermarkEnabled: config.enabled,
}
: {}),
},
});
if (claimed.count === 0) {
return 'retry' as const;
}
await tx.watermark_configs.create({
data: {
albumId,
configVersion: nextVersion,
config: config as unknown as Prisma.InputJsonValue,
},
});
const job = await tx.watermark_jobs.create({
data: {
albumId,
configVersion: nextVersion,
state: autoDone ? 'done' : 'pending',
total,
},
select: { id: true },
});
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,
priority: 'normal',
payload: buildAlbumUpdatedPayload(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.watermark',
resourceId: albumId,
metadata: {
configVersion: nextVersion,
enabled: config.enabled,
jobId: job.id,
},
},
});
jobId = job.id;
configVersion = nextVersion;
needsWorker = !autoDone;
return 'done' as const;
});
}
if (outcome !== 'done') {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (needsWorker) {
await this.queue.enqueueWatermarkRegen(jobId);
}
return { jobId, configVersion };
}
/**
* 当前配置(最新版本;从未配置 → null)与任务列表(创建时间倒序,上限 50)。
*/
async getStatus(
user: AuthUser,
albumId: string,
): Promise<WatermarkJobsStatusResponse> {
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
if (!(await this.permissions.canManageAlbum(user, albumId))) {
throw albumForbidden('仅创建者或管理员可查看水印状态');
}
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
});
if (album === null) {
throw albumNotFound();
}
const latestConfig = await this.prisma.watermark_configs.findFirst({
where: { albumId },
orderBy: { configVersion: 'desc' },
});
const jobs = await this.prisma.watermark_jobs.findMany({
where: { albumId },
orderBy: { createdAt: 'desc' },
take: WATERMARK_JOBS_LIST_LIMIT,
});
return {
current:
latestConfig === null
? null
: {
configVersion: latestConfig.configVersion,
// 行由 normalizeWatermarkConfig 写入;读取侧仍走解析防御脏数据
// (结构破坏按 null——「配置不可用」而非 5xx,状态端点不因单行
// 脏数据整页失败)。
config: parseStoredWatermarkConfig(latestConfig.config) ?? {
enabled: false,
position: WATERMARK_DEFAULT_POSITION,
opacity: WATERMARK_DEFAULT_OPACITY,
scalePercent: WATERMARK_DEFAULT_SCALE_PERCENT,
text: {
content: '',
fontSize: WATERMARK_DEFAULT_FONT_SIZE,
},
},
},
jobs: jobs.map((job) => ({
jobId: job.id,
configVersion: job.configVersion,
state: job.state as components['schemas']['WatermarkJobState'],
progress: job.progress,
total: job.total,
failed: job.failed,
...(job.errorCode === null
? {}
: {
// worker 任务码(WATERMARK_CONFIG_MISSING 等)是 VarChar(64) 内部
// 码,契约 Error 枚举未收录——非枚举值统一译 INTERNAL_ERROR 保持
// 线上响应契约合法(openapi 增补 WATERMARK_* 枚举值登记后续任务)。
errorCode: (
Object.values(ErrorCodes) as readonly string[]
).includes(job.errorCode)
? (job.errorCode as components['schemas']['ErrorCode'])
: ErrorCodes.INTERNAL_ERROR,
}),
createdAt: job.createdAt.toISOString(),
})),
};
}
}