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(),
})),
};
}
}
@@ -13,6 +13,9 @@
// 生效 banner」):cn 侧语言偏好解析(locale 行 → 全局行)在 ov 副本上
// 退化为最新键——多语言 banner 的 ov 侧精确解析登记为 P5 展示 refinement
// (本地仿真 e2e 用单全局 banner 验证同形);
// · 浏览图水印(P4a Task 3):ov 侧无变体副本表,水印开启时 browse 键由
// 模式推导(variants/{photoId}/{有效版本}/watermark.jpg,cn worker 直写
// 同一对象空间)——对象存在性由 done 任务收尾不变量保证(见 ovBrowseKey);
// · 列表新增 sort=hot(plan Task 6 / 技术方案 §7):likeTotal 倒序 + photoId
// 倒序决胜,**短期快照语义**——每相册内存缓存排好序的 photoId 清单
// (TTL 缺省 30s,env HOT_SORT_TTL_MS 可调,惰性读取),窗口内翻页对同一
@@ -36,6 +39,7 @@ import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
import { signedUrlTtlSeconds } from '../uploads/support';
import { albumNotPublic, photoNotPublic } from '../public/support';
import { STORAGE_PORT_OV } from './storage-ov.provider';
import { watermarkVariantObjectKey } from '@photo/storage';
import type { StoragePort } from '@photo/storage';
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
@@ -80,6 +84,13 @@ interface HotSnapshot {
expiresAt: number;
}
/** 相册级水印服务上下文(albums_ov 副本位 + 归属 albumId,见 ovBrowseKey)。 */
interface WatermarkContext {
albumId: string;
enabled: boolean;
configVersion: number;
}
/**
* 热门快照 offset 游标:base64url JSON 数字(快照内下一页起始下标)。快照在
* TTL 内冻结,下标分页严格稳定(无并发漂移);解码失败 → null(调用方 400,
@@ -205,7 +216,12 @@ export class PublicOvService {
: {};
if (query.sort === 'hot') {
return this.listHot(album.albumId, categoryFilter, query);
return this.listHot(
album.albumId,
categoryFilter,
query,
this.watermarkContext(album),
);
}
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
@@ -230,7 +246,7 @@ export class PublicOvService {
});
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
const items = await this.toItems(page);
const items = await this.toItems(page, this.watermarkContext(album));
// 游标取自「DB 页末行」(同 cn:被跳过的行不进下一页重扫)。
const last = page.at(-1);
const nextCursor =
@@ -270,7 +286,13 @@ export class PublicOvService {
width: photo.width,
height: photo.height,
thumb: await this.presign(photo.thumbObjectKey),
browse: await this.presign(photo.browseObjectKey),
browse: await this.presign(
this.ovBrowseKey(
this.watermarkContext(album),
photo.photoId,
photo.browseObjectKey,
),
),
likeCount: photo.likeTotal,
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
categoryId: photo.categoryId,
@@ -282,6 +304,7 @@ export class PublicOvService {
albumId: string,
categoryFilter: { categoryId?: string },
query: { cursor?: string; limit: number },
watermark: WatermarkContext,
): Promise<PublicPhotoListResponse> {
let offset = 0;
if (query.cursor !== undefined && query.cursor !== '') {
@@ -305,7 +328,7 @@ export class PublicOvService {
const ordered = pageIds
.map((id) => byId.get(id))
.filter((row) => row !== undefined);
const items = await this.toItems(ordered);
const items = await this.toItems(ordered, watermark);
const nextCursor = hasMore ? encodeHotCursor(offset + query.limit) : null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
@@ -347,6 +370,7 @@ export class PublicOvService {
likeTotal: number;
publishedAt: Date | null;
}>,
watermark: WatermarkContext,
): Promise<PublicPhotoListResponse['items']> {
const items: PublicPhotoListResponse['items'] = [];
for (const photo of page) {
@@ -358,7 +382,9 @@ export class PublicOvService {
width: photo.width,
height: photo.height,
thumb: await this.presign(photo.thumbObjectKey),
browse: await this.presign(photo.browseObjectKey),
browse: await this.presign(
this.ovBrowseKey(watermark, photo.photoId, photo.browseObjectKey),
),
likeCount: photo.likeTotal,
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
});
@@ -366,6 +392,42 @@ export class PublicOvService {
return items;
}
/** albums_ov 水印副本位(watermarkEnabled + watermarkConfigVersion + 归属)。 */
private watermarkContext(album: {
albumId: string;
watermarkEnabled: boolean;
watermarkConfigVersion: number;
}): WatermarkContext {
return {
albumId: album.albumId,
enabled: album.watermarkEnabled,
configVersion: album.watermarkConfigVersion,
};
}
/**
* ov 浏览图对象键(P4a Task 3):水印开启时用**模式键**——
* variants/{photoId}/{watermarkConfigVersion}/watermark.jpg(同一对象空间,
* cn worker 直写;ov 无变体副本表,键由 cn 行结构确定)。副本位携带的是
* **有效版本**(albums.watermarkEffectiveVersion,仅 done 任务推进):该版本
* 键的存在性由重生成任务的收尾不变量保证(每张已发布照片处理成功或回退拷贝
* 旧图到目标键——失败照片保留旧图,A20),disable 版任务完成后 enabled=false
* 回落 browse 内联键(水印键对象保留,历史可溯)。
*/
private ovBrowseKey(
watermark: WatermarkContext,
photoId: string,
browseObjectKey: string,
): string {
return watermark.enabled && watermark.configVersion > 0
? watermarkVariantObjectKey(
watermark.albumId,
photoId,
watermark.configVersion,
)
: browseObjectKey;
}
/** publicToken → albums_ov 副本;不存在 / 不可见(含紧急停用)统一 404。 */
private async findPublicAlbumOv(token: string) {
const album = await this.prisma.albums_ov.findUnique({
+24 -2
View File
@@ -222,6 +222,15 @@ export class PublicService {
bucket.set(row.variant, row);
}
// P4a Task 3 水印浏览图选择(cn):albums.watermarkEnabled(有效版本开关)
// 且照片有 watermark 变体行 → 现签水印对象键;否则 browse。行是对象 put
// 成功后才落库的(worker 先写对象再 upsert 行),故行存在 ⇒ 对象存在;
// 失败照片行保留旧版本键(重生成失败不覆盖行——A20「失败照片保留旧图」),
// 新发布照片行在最新版本键(WatermarkJobCreated:新照片即刻套用)——两者
// 都直接用行的 objectKey,无需版本比较。disable 版任务逐张删行:删到哪张
// 哪张回落 browse(渐进撤下,语义见 contracts watermark.ts)。
const serveWatermark = album.watermarkEnabled;
const items: PublicPhotoListResponse['items'] = [];
for (const photo of page) {
const bucket = variantsByPhoto.get(photo.id);
@@ -230,8 +239,14 @@ export class PublicService {
if (thumbRow === undefined || browseRow === undefined) {
continue;
}
const watermarkRow = serveWatermark
? bucket?.get('watermark')
: undefined;
const browseKey =
watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey;
// 宽高取 browse 变体尺寸(契约「浏览图宽高,用于列表占位」);变体行缺
// 尺寸时回退 photos 主行(原始尺寸),两侧均缺 → 同样跳过(必返字段)。
// 水印图与 browse 同源同尺寸(compose 基于 browse 管线),占位一致。
const width = browseRow.width ?? photo.width;
const height = browseRow.height ?? photo.height;
if (width === null || height === null) {
@@ -242,7 +257,7 @@ export class PublicService {
width,
height,
thumb: await this.presign(thumbRow.objectKey),
browse: await this.presign(browseRow.objectKey),
browse: await this.presign(browseKey),
likeCount: photo.likeCount,
// published 行的 publishedAt 必非空(worker 发布事务写入);?? 兜底仅
// 收窄类型,不构成可观察行为。
@@ -290,6 +305,11 @@ export class PublicService {
});
const thumbRow = variants.find((v) => v.variant === 'thumb');
const browseRow = variants.find((v) => v.variant === 'browse');
// 水印浏览图选择同列表(有效开关 + 行存在 ⇒ 用行的对象键;A20 失败照片
// 保留旧图——行未被失败任务覆盖,仍指向旧版本键)。
const watermarkRow = album.watermarkEnabled
? variants.find((v) => v.variant === 'watermark')
: undefined;
const width = browseRow?.width ?? photo.width;
const height = browseRow?.height ?? photo.height;
if (
@@ -301,12 +321,14 @@ export class PublicService {
// post-publish 不变量破坏:公开面按「不可展示」处理(同列表跳过语义)。
throw photoNotPublic();
}
const browseKey =
watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey;
return {
photoId: photo.id,
width,
height,
thumb: await this.presign(thumbRow.objectKey),
browse: await this.presign(browseRow.objectKey),
browse: await this.presign(browseKey),
likeCount: photo.likeCount,
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
categoryId: photo.categoryId,
@@ -221,6 +221,13 @@ export class SyncInboxService {
const categorySnapshot = Array.isArray(p.categories)
? (p.categories as unknown as Prisma.InputJsonValue)
: undefined;
// P4a Task 3 水印副本位:旧事件/手写载荷可能缺字段——防御性归一(缺省=
// 关闭/0),语义上「缺水印位」与「水印关闭」同形。
const watermarkEnabled = p.watermarkEnabled === true;
const watermarkConfigVersion =
typeof p.watermarkConfigVersion === 'number' && p.watermarkConfigVersion > 0
? Math.floor(p.watermarkConfigVersion)
: 0;
await tx.albums_ov.upsert({
where: { albumId: event.entityId },
create: {
@@ -230,6 +237,8 @@ export class SyncInboxService {
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
watermarkEnabled,
watermarkConfigVersion,
translations: p.translations as unknown as Prisma.InputJsonValue,
categories: categorySnapshot ?? [],
defaultLocale: p.defaultLocale,
@@ -244,6 +253,8 @@ export class SyncInboxService {
publicEnabled: p.publicEnabled,
downloadEnabled: p.downloadEnabled,
originalDownloadEnabled: p.originalDownloadEnabled,
watermarkEnabled,
watermarkConfigVersion,
translations: p.translations as unknown as Prisma.InputJsonValue,
...(categorySnapshot !== undefined
? { categories: categorySnapshot }
+25 -43
View File
@@ -10,7 +10,7 @@
// bootstrap/reconcile 同一出口);worker publish.service 的首次发布载荷来自
// 流水线内存值(同构不同源),保持不动。
import { HttpStatus } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ErrorCodes, buildAlbumUpdatedPayloadCore } from '@photo/contracts';
import type {
AlbumUpdatedPayload,
AssetUpdatedPayload,
@@ -31,6 +31,8 @@ type AlbumRow = Pick<
| 'publicEnabled'
| 'downloadEnabled'
| 'originalDownloadEnabled'
| 'watermarkEnabled'
| 'watermarkEffectiveVersion'
| 'defaultLocale'
| 'timezone'
| 'startAt'
@@ -52,6 +54,8 @@ type PhotoVariantRow = Prisma.photo_variantsGetPayload<
/**
* album.updated 载荷:albums_ov 展示所需标量 + 翻译全集(locale 升序)。
* P4a Task 3 起标量全集(含水印副本位)的构建收敛到 contracts 的共享实现
* (worker 完成事务同用,见 buildAlbumUpdatedPayloadCore);本包装仅叠加
* categories 可选参数:仅 bootstrap/reconcile 快照事件传入(ov 整体替换
* albums_ov.categories 的收敛原语,见 contracts events.ts 注释);业务事务内
* 增量事件不传(分类元素归 category.* 事件管)。输出按 (sortOrder, id) 升序
@@ -62,48 +66,26 @@ export function buildAlbumUpdatedPayload(
translations: AlbumTranslationRow[],
categories?: AlbumCategoryInput[],
): AlbumUpdatedPayload {
return {
albumId: album.id,
publicToken: album.publicToken,
status: album.status,
uploadEnabled: album.uploadEnabled,
publicEnabled: album.publicEnabled,
downloadEnabled: album.downloadEnabled,
originalDownloadEnabled: album.originalDownloadEnabled,
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,
})),
...(categories === undefined
? {}
: {
categories: [...categories]
.sort((a, b) =>
a.category.sortOrder !== b.category.sortOrder
? a.category.sortOrder - b.category.sortOrder
: a.category.id < b.category.id
? -1
: 1,
)
.map(({ category, translations: categoryTranslations }) => ({
id: category.id,
sortOrder: category.sortOrder,
translations: [...categoryTranslations]
.sort((a, b) => (a.locale < b.locale ? -1 : 1))
.map((t) => ({ locale: t.locale, name: t.name })),
})),
}),
entityVersion: album.entityVersion,
};
const payload = buildAlbumUpdatedPayloadCore(album, translations);
if (categories === undefined) {
return payload;
}
payload.categories = [...categories]
.sort((a, b) =>
a.category.sortOrder !== b.category.sortOrder
? a.category.sortOrder - b.category.sortOrder
: a.category.id < b.category.id
? -1
: 1,
)
.map(({ category, translations: categoryTranslations }) => ({
id: category.id,
sortOrder: category.sortOrder,
translations: [...categoryTranslations]
.sort((a, b) => (a.locale < b.locale ? -1 : 1))
.map((t) => ({ locale: t.locale, name: t.name })),
}));
return payload;
}
/** 快照 categories 参数形状:分类行 + 该分类的翻译行(见函数注释)。 */
+6 -2
View File
@@ -6,11 +6,15 @@
import { randomBytes } from 'node:crypto';
import { HttpStatus } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { OBJECT_KEY_ROOT } from '@photo/storage';
import { ApiException } from '../api-exception';
import { albumNotFound } from '../albums/support';
/** 对象键根(与 test/reset.ts 的 MinIO 清理前缀一致)。 */
export const OBJECT_KEY_ROOT = 'photo-live/dev';
/**
* 对象键根:P4a Task 3 起权威出处收敛到 @photo/storage keys.ts(ov 公开侧
* 水印模式键推导引入第三消费方),此处转出口维持既有导入面。
*/
export { OBJECT_KEY_ROOT };
/** UPLOAD_MAX_BYTES 缺省 50MB(与 .env.example 同步)。 */
export const DEFAULT_UPLOAD_MAX_BYTES = 52_428_800;
+2 -4
View File
@@ -19,6 +19,7 @@ import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import {
createStorageFromEnv,
variantsObjectKey,
type MinioStorageAdapter,
} from '@photo/storage';
import { AppCnModule } from '../src/app-cn.module';
@@ -28,10 +29,7 @@ import { API_PREFIX } from '../src/shared/constants';
import {
createPhotoProcessWorker,
} from '../../worker/src/process.processor';
import {
PublishService,
variantsObjectKey,
} from '../../worker/src/publish.service';
import { PublishService } from '../../worker/src/publish.service';
import { resetDb } from './reset';
jest.setTimeout(180_000);
+4
View File
@@ -24,6 +24,10 @@ export const RESET_TABLES: readonly string[] = [
'photo_variants',
'upload_sessions',
'photos',
// 水印域(P4a Task 3):任务/配置账本先于 albums(watermarkVersion 等位列
// 随 albums 清零)。
'watermark_jobs',
'watermark_configs',
// 相册域(Task 9):子表(翻译/资产/成员)在前,albums 在后;相册域为扁平
// 字符串外键(无 FK 约束),此处顺序仅为逻辑可读,不依赖 FK 检查开关。
'category_translations',
+687
View File
@@ -0,0 +1,687 @@
// services/api/test/watermark.e2e-spec.ts —— P4a Task 3 水印版本化 + 历史重生成
// 全链路 e2e。ONE spec 串完整业务流(cn API → worker 重生成(直驱,BullMQ 消费
// 由 worker/test/watermark.e2e-spec.ts 覆盖)→ cn/ov 公开侧服务 → ov 副本传播)。
// 进程内起两个真实入口(cn + ov,同 public-ov.e2e-spec 模式:OutboxSender 指向
// ov 真实 URL,ov 读到的数据与生产链路同源)。覆盖(任务验收):
// ① POST watermark-jobs(enabled):202 {jobId, configVersion};GET 状态形状
// (current=最新配置缺省填齐 / jobs 创建时间倒序 / pending+total);
// ② 新照片发布即套用最新版(POST 后、任务 done 前发布 → watermark 变体对象
// 已产且与 browse 字节不同——契约 WatermarkJobCreated 语义);
// ③ 历史重生成(3 张):直驱 processJob → done(progress=total=3, failed=0)
// → cn 公开列表 browse 现签 URL 取回**水印字节**(≠ browse 变体对象);
// ④ A20:原图 objectKey 与字节全程零触碰;
// ⑤ ov 传播:drainSender 后 albums_ov.watermarkEnabled/ConfigVersion 就位;
// ov 公开列表 browse URL = 模式键 variants/{photoId}/{v}/watermark.jpg,
// 取回字节与 cn 水印对象一致;
// ⑥ 失败注入:删 1 张原图 → 新版本任务 done 且 failed=1;失败张 cn 保留旧版
// 水印(行未覆盖)、ov 模式键 = 旧图拷贝(同字节——失败照片保留旧图);
// 恢复原图后 re-POST → failed=0 全量收敛;
// ⑦ 关闭版重生成:done 后 cn/ov 公开列表回落 browse 键;水印行删除、对象
// 保留(历史可溯);
// ⑧ 空相册:POST 即建即 done(无照片可重生成),有效位当场推进并同步 ov;
// ⑨ 权限:协作者 POST/GET → 403;圈外人 → 404;admin → 202/200;
// ⑩ 校验:enabled=true 缺 text.content → 400;opacity/scalePercent/fontSize
// 越界、position 非法 → 400。
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import request from 'supertest';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
import { ErrorCodes } from '@photo/contracts';
import {
createStorageFromEnv,
watermarkVariantObjectKey,
type MinioStorageAdapter,
type StoragePort,
} from '@photo/storage';
import { AppCnModule } from '../src/app-cn.module';
import { AppOvModule } from '../src/app-ov.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { configureApp } from '../src/shared/configure-app';
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
import { PublishService } from '../../worker/src/publish.service';
import {
WatermarkRegenService,
} from '../../worker/src/watermark.service';
import { OutboxSender } from '../../sync/src/sender';
import { resetDb } from './reset';
import { resetOvDb } from './reset-ov';
jest.setTimeout(180_000);
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
/** 测试内生成真实 JPEG(带纹理,水印差异可观测;无二进制入库)。 */
async function makeJpeg(
width: number,
height: number,
tint: number,
): Promise<Buffer> {
const svg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
Array.from(
{ length: 10 },
(_, i) =>
`<rect x="${(i * 79) % width}" y="${(i * 43) % height}" width="${45 + i * 8}" height="${32 + i * 6}" fill="#${(tint + i * 11).toString(16).padStart(2, '0')}7040" fill-opacity="0.85"/>`,
).join('') +
'</svg>',
);
return sharp(svg).jpeg({ quality: 85 }).toBuffer();
}
/** 流式读取对象为单缓冲(与 worker readOriginal 同约定)。 */
async function readObject(storage: StoragePort, key: string): Promise<Buffer> {
const stream = (await storage.getStream(key)) 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);
}
/** S3 协议直删单对象(StoragePort 无删除——YAGNI,仅测试注入用)。 */
async function deleteObject(key: string): Promise<void> {
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY as string,
secretAccessKey: process.env.S3_SECRET_KEY as string,
},
forcePathStyle: true,
});
try {
await client.send(new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }));
} finally {
await client.destroy();
}
}
describe('watermark (e2e)', () => {
let cnApp: NestExpressApplication;
let ovApp: NestExpressApplication;
let cnBase: string;
let ovBase: string;
let prismaCn: PrismaClient;
let prismaOv: OvPrismaClient;
let storage: MinioStorageAdapter;
let sender: OutboxSender;
let regen: WatermarkRegenService;
let publish: PublishService;
let ownerId: string;
let ownerToken: string;
let collabToken: string;
let outsiderToken: string;
let adminToken: string;
let albumId: string;
let publicToken: string;
/** 上传顺序的三张照片(v1 重生成目标)。 */
let photoIds: string[];
let originals: Map<string, Buffer>;
let photoIdOfFailed: string;
/** 排空发送器:循环 runOnce 直至无未投递事件(同 public-ov spec)。 */
async function drainSender(): Promise<void> {
for (let round = 0; round < 20; round++) {
const stats = await sender.runOnce();
if (stats.picked === 0) {
return;
}
}
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
}
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
async function uploadAndPublish(clientTaskId: string, jpeg: Buffer): Promise<string> {
const init = await request(cnBase)
.post('/v1/uploads')
.set('Authorization', `Bearer ${ownerToken}`)
.send({
albumId,
clientTaskId,
filename: 'wm.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
});
expect(init.status).toBe(201);
const { uploadId, photoId, credential } = init.body as {
uploadId: string;
photoId: string;
credential: { url: string; method: string; headers: Record<string, string> };
};
const put = await fetch(credential.url, {
method: credential.method,
headers: credential.headers,
body: new Uint8Array(jpeg),
});
expect(put.ok).toBe(true);
const done = await request(cnBase)
.post(`/v1/uploads/${uploadId}/complete`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(done.status).toBe(200);
expect(await publish.processPhoto(photoId)).toBe('published');
originals.set(photoId, jpeg);
return photoId;
}
const postWatermark = (token: string, body: object) =>
request(cnBase)
.post(`/v1/albums/${albumId}/watermark-jobs`)
.set('Authorization', `Bearer ${token}`)
.send(body);
const getWatermark = (token: string) =>
request(cnBase)
.get(`/v1/albums/${albumId}/watermark-jobs`)
.set('Authorization', `Bearer ${token}`);
/** 取回照片的 browse 变体对象字节(对照基准)。 */
const browseObjectOf = async (photoId: string): Promise<Buffer> => {
const row = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId, variant: 'browse' } },
});
return readObject(storage, row.objectKey);
};
beforeAll(async () => {
expect(SYNC_KEY).not.toBe('');
await resetDb();
await resetOvDb();
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
rawBody: true,
});
configureApp(cnApp);
await cnApp.listen(0);
cnBase = await cnApp.getUrl();
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
rawBody: true,
});
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
configureApp(ovApp);
await ovApp.listen(0);
ovBase = await ovApp.getUrl();
prismaCn = new PrismaClient();
prismaOv = new OvPrismaClient();
storage = createStorageFromEnv();
sender = new OutboxSender({ prisma: prismaCn, ovApiUrl: ovBase, internalKey: SYNC_KEY });
regen = new WatermarkRegenService(prismaCn, storage);
publish = new PublishService(prismaCn, storage);
const mkUser = (account: string, extra: { role?: string } = {}) =>
prismaCn.users.create({
data: {
account,
role: extra.role ?? 'staff',
passwordHash: 'x',
displayName: account,
},
select: { id: true },
});
ownerId = (await mkUser('wm-owner')).id;
const collabId = (await mkUser('wm-collab')).id;
const outsiderId = (await mkUser('wm-outsider')).id;
const adminId = (await mkUser('wm-admin', { role: 'admin' })).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prismaCn.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
ownerToken = await mkToken(ownerId);
collabToken = await mkToken(collabId);
outsiderToken = await mkToken(outsiderId);
adminToken = await mkToken(adminId);
const album = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '水印验收相册' },
{ locale: 'en', title: 'Watermark Gala' },
],
defaultLocale: 'zh-CN',
startAt: '2026-11-01T10:00:00.000Z',
});
expect(album.status).toBe(201);
albumId = album.body.id as string;
publicToken = album.body.publicToken as string;
// 协作者(可见不可管)与三张已发布照片。
const member = await request(cnBase)
.post(`/v1/albums/${albumId}/members`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ userId: collabId, role: 'collaborator' });
expect(member.status).toBe(201);
originals = new Map();
photoIds = [];
for (let i = 0; i < 3; i++) {
photoIds.push(
await uploadAndPublish(
`wm-seed-${i}`,
await makeJpeg(900 - i * 60, 600 - i * 40, 0x70 + i * 0x18),
),
);
}
await drainSender();
});
afterAll(async () => {
await cnApp.close();
await ovApp.close();
await prismaCn.$disconnect();
await prismaOv.$disconnect();
});
it('① POST(enabled)→ 202 {jobId, configVersion};GET 状态形状(缺省填齐/pending)', async () => {
const res = await postWatermark(ownerToken, {
enabled: true,
text: { content: '© Photo Live' },
});
expect(res.status).toBe(202);
expect(res.body).toEqual({ jobId: expect.any(String), configVersion: 1 });
expect((res.body.jobId as string).length).toBeGreaterThan(0);
// albums.watermarkVersion(最新创建版本)即刻推进;有效位未动(任务未 done)。
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkVersion).toBe(1);
expect(album.watermarkEffectiveVersion).toBe(0);
expect(album.watermarkEnabled).toBe(false);
// 审计行。
const audit = await prismaCn.audit_logs.findFirstOrThrow({
where: { action: 'album.watermark', resourceId: albumId },
});
expect(audit.metadata).toMatchObject({ configVersion: 1, enabled: true });
const status = await getWatermark(ownerToken);
expect(status.status).toBe(200);
expect(status.body.current).toEqual({
configVersion: 1,
// 缺省填齐(契约 WatermarkConfig 可选字段的服务端默认值)。
config: {
enabled: true,
position: 'bottomRight',
opacity: 50,
scalePercent: 20,
text: { content: '© Photo Live', fontSize: 24 },
},
});
expect(status.body.jobs).toHaveLength(1);
expect(status.body.jobs[0]).toMatchObject({
configVersion: 1,
state: 'pending',
progress: 0,
total: 3,
failed: 0,
});
expect(status.body.jobs[0].jobId).toBe(res.body.jobId);
expect(typeof status.body.jobs[0].createdAt).toBe('string');
expect(status.body.jobs[0].errorCode).toBeUndefined();
});
it('② 新照片发布即套用最新版:POST 后发布 → watermark 变体对象已产且 ≠ browse', async () => {
const photoId = await uploadAndPublish('wm-after-post', await makeJpeg(760, 500, 0x99));
photoIds.push(photoId);
// 发布路径写入 watermark 行(version=1=最新创建版本)+ 对象存在且非空 jpeg,
// 与 browse 变体字节不同(水印本身即差异——A18「新照片即刻套用」)。
const row = await prismaCn.photo_variants.findUnique({
where: { photoId_variant: { photoId, variant: 'watermark' } },
});
expect(row?.version).toBe(1);
expect(row?.objectKey).toBe(watermarkVariantObjectKey(albumId, photoId, 1));
const wmObject = await readObject(storage, row!.objectKey);
expect(wmObject.length).toBeGreaterThan(0);
expect((await sharp(wmObject).metadata()).format).toBe('jpeg');
expect(wmObject.equals(await browseObjectOf(photoId))).toBe(false);
});
it('③ 历史重生成 → done;cn 公开列表服务水印字节;④ A20 原图零触碰;⑤ ov 模式键', async () => {
const pending = await getWatermark(ownerToken);
const jobId = pending.body.jobs[0].jobId as string;
expect(await regen.processJob(jobId)).toBe('done');
const status = await getWatermark(ownerToken);
expect(status.body.jobs[0]).toMatchObject({
state: 'done',
// total = POST 时点的已发布照片快照(3);progress 含 ② 追加的 1 张
// (发布中途的新照片自然纳入处理——progress 计「已处理」,可超过 total)。
progress: 4,
total: 3,
failed: 0,
});
// 有效位推进(仅 done 后——ov 切换的前提)。
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEffectiveVersion).toBe(1);
expect(album.watermarkEnabled).toBe(true);
// cn 公开列表:每条 browse 现签 URL 取回的是**水印字节**(≠ browse 变体)。
const list = await request(cnBase)
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
expect(list.status).toBe(200);
expect(list.body.items.map((i: { photoId: string }) => i.photoId).sort()).toEqual(
[...photoIds].sort(),
);
for (const item of list.body.items as Array<{
photoId: string;
browse: { url: string };
}>) {
const fetched = Buffer.from(await (await fetch(item.browse.url)).arrayBuffer());
expect(fetched.length).toBeGreaterThan(0);
expect(fetched.equals(await browseObjectOf(item.photoId))).toBe(false);
}
// A20:原图对象键与字节零触碰。
for (const [photoId, original] of originals) {
const photo = await prismaCn.photos.findUniqueOrThrow({ where: { id: photoId } });
expect(photo.objectKey).toBe(
`photo-live/dev/albums/${albumId}/originals/${photoId}.jpg`,
);
expect(await readObject(storage, photo.objectKey)).toEqual(original);
}
// ov:副本位 + 模式键。发送器排空后 album.updated(有效位推进)到达 ov。
await drainSender();
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({
where: { albumId },
});
expect(ovAlbum.watermarkEnabled).toBe(true);
expect(ovAlbum.watermarkConfigVersion).toBe(1);
const ovList = await request(ovBase)
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
expect(ovList.status).toBe(200);
expect(ovList.body.items).toHaveLength(photoIds.length);
for (const item of ovList.body.items as Array<{
photoId: string;
browse: { url: string };
}>) {
// 模式键(确定性问题:URL 路径含版本段 watermark 对象键)。
expect(item.browse.url).toContain(
`/albums/${albumId}/variants/${item.photoId}/1/watermark.jpg`,
);
// 取回字节与 cn 水印对象一致(同一对象、两域各自现签)。
const cnRow = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId: item.photoId, variant: 'watermark' } },
});
const fetched = Buffer.from(await (await fetch(item.browse.url)).arrayBuffer());
expect(fetched.equals(await readObject(storage, cnRow.objectKey))).toBe(true);
}
});
it('⑥ 失败注入:删 1 张原图 → 新版本 done 且 failed=1;失败张两侧保留旧图;恢复后 re-POST 收敛', async () => {
photoIdOfFailed = photoIds[1]!;
const failedPhoto = await prismaCn.photos.findUniqueOrThrow({
where: { id: photoIdOfFailed },
});
const failedOriginal = originals.get(photoIdOfFailed)!;
await deleteObject(failedPhoto.objectKey);
// v1 水印对象字节(失败张的「旧图」基准)。
const v1Key = watermarkVariantObjectKey(albumId, photoIdOfFailed, 1);
const v1Object = await readObject(storage, v1Key);
const res = await postWatermark(ownerToken, {
enabled: true,
text: { content: 'v2 水印' },
position: 'topRight',
});
expect(res.status).toBe(202);
expect(res.body.configVersion).toBe(2);
expect(await regen.processJob(res.body.jobId)).toBe('done');
const status = await getWatermark(ownerToken);
expect(status.body.jobs[0]).toMatchObject({
configVersion: 2,
state: 'done',
progress: 4,
total: 4,
failed: 1,
});
// 成功张:行推进到 v2。
for (const photoId of photoIds.filter((id) => id !== photoIdOfFailed)) {
const row = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId, variant: 'watermark' } },
});
expect(row.version).toBe(2);
}
// 失败张:cn 行保留 v1(未覆盖——保留旧图),ov 模式键 v2 = v1 旧图拷贝。
const failedRow = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId: photoIdOfFailed, variant: 'watermark' } },
});
expect(failedRow.version).toBe(1);
expect(
await readObject(storage, watermarkVariantObjectKey(albumId, photoIdOfFailed, 2)),
).toEqual(v1Object);
await drainSender();
// cn:失败张 browse URL 取回 v1 旧图字节;成功张取回 v2。
const cnList = await request(cnBase)
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
const byPhoto = new Map(
(cnList.body.items as Array<{ photoId: string; browse: { url: string } }>).map(
(i) => [i.photoId, i.browse.url],
),
);
expect(
Buffer.from(await (await fetch(byPhoto.get(photoIdOfFailed)!)).arrayBuffer()),
).toEqual(v1Object);
const okPhoto = photoIds[0]!;
const okV2 = await readObject(
storage,
watermarkVariantObjectKey(albumId, okPhoto, 2),
);
expect(
Buffer.from(await (await fetch(byPhoto.get(okPhoto)!)).arrayBuffer()),
).toEqual(okV2);
// ov:失败张模式键 v2 同样是旧图字节(与 cn 一致——A20 双域同语义)。
const ovList = await request(ovBase)
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
const ovByPhoto = new Map(
(ovList.body.items as Array<{ photoId: string; browse: { url: string } }>).map(
(i) => [i.photoId, i.browse.url],
),
);
expect(ovByPhoto.get(photoIdOfFailed)).toContain(
`/variants/${photoIdOfFailed}/2/watermark.jpg`,
);
expect(
Buffer.from(await (await fetch(ovByPhoto.get(photoIdOfFailed)!)).arrayBuffer()),
).toEqual(v1Object);
// —— 恢复原图 → re-POST v3 → failed=0 全量收敛(重新发起语义)——
await storage.put(failedPhoto.objectKey, failedOriginal, 'image/jpeg');
const retry = await postWatermark(ownerToken, {
enabled: true,
text: { content: 'v3 retry' },
});
expect(retry.status).toBe(202);
expect(retry.body.configVersion).toBe(3);
expect(await regen.processJob(retry.body.jobId)).toBe('done');
const after = await getWatermark(ownerToken);
expect(after.body.jobs[0]).toMatchObject({
configVersion: 3,
state: 'done',
failed: 0,
progress: 4,
});
const recovered = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId: photoIdOfFailed, variant: 'watermark' } },
});
expect(recovered.version).toBe(3);
expect(
(await readObject(storage, watermarkVariantObjectKey(albumId, photoIdOfFailed, 3))).equals(
v1Object,
),
).toBe(false);
});
it('⑦ 关闭版重生成:两侧回落 browse 键;水印行删除、对象保留', async () => {
const res = await postWatermark(ownerToken, { enabled: false });
expect(res.status).toBe(202);
expect(res.body.configVersion).toBe(4);
expect(await regen.processJob(res.body.jobId)).toBe('done');
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: albumId } });
expect(album.watermarkEnabled).toBe(false);
expect(album.watermarkEffectiveVersion).toBe(4);
// 行全删(对象保留——历史可溯)。
expect(
await prismaCn.photo_variants.count({ where: { photoId: { in: photoIds }, variant: 'watermark' } }),
).toBe(0);
for (const photoId of photoIds) {
expect(
await storage.head(watermarkVariantObjectKey(albumId, photoId, 3)),
).not.toBeNull();
}
await drainSender();
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId } });
expect(ovAlbum.watermarkEnabled).toBe(false);
expect(ovAlbum.watermarkConfigVersion).toBe(4);
// cn/ov 公开列表:browse URL 全部回落 browse 变体键,取回字节=browse 对象。
for (const base of [cnBase, ovBase]) {
const list = await request(base)
.get(`/v1/public/albums/${publicToken}/photos?limit=60`);
expect(list.status).toBe(200);
for (const item of list.body.items as Array<{
photoId: string;
browse: { url: string };
}>) {
expect(item.browse.url).toContain(`/variants/${item.photoId}/1/browse.jpg`);
const fetched = Buffer.from(await (await fetch(item.browse.url)).arrayBuffer());
expect(fetched.equals(await browseObjectOf(item.photoId))).toBe(true);
}
}
});
it('⑧ 空相册:POST 即建即 done,有效位当场推进并同步 ov', async () => {
const emptyAlbum = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [{ locale: 'zh-CN', title: '空相册水印' }],
defaultLocale: 'zh-CN',
startAt: '2026-11-20T10:00:00.000Z',
});
expect(emptyAlbum.status).toBe(201);
const emptyId = emptyAlbum.body.id as string;
const res = await request(cnBase)
.post(`/v1/albums/${emptyId}/watermark-jobs`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ enabled: true, text: { content: '空相册' } });
expect(res.status).toBe(202);
const status = await request(cnBase)
.get(`/v1/albums/${emptyId}/watermark-jobs`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(status.body.jobs[0]).toMatchObject({ state: 'done', total: 0, progress: 0 });
const album = await prismaCn.albums.findUniqueOrThrow({ where: { id: emptyId } });
expect(album.watermarkEnabled).toBe(true);
expect(album.watermarkEffectiveVersion).toBe(1);
await drainSender();
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId: emptyId } });
expect(ovAlbum.watermarkEnabled).toBe(true);
expect(ovAlbum.watermarkConfigVersion).toBe(1);
});
it('⑨ 权限:协作者 403 / 圈外人 404 / admin 可管;未认证 401', async () => {
const collabPost = await postWatermark(collabToken, {
enabled: true,
text: { content: 'x' },
});
expect(collabPost.status).toBe(403);
expect(collabPost.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const collabGet = await getWatermark(collabToken);
expect(collabGet.status).toBe(403);
expect(collabGet.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const outsider = await request(cnBase)
.post(`/v1/albums/${albumId}/watermark-jobs`)
.set('Authorization', `Bearer ${outsiderToken}`)
.send({ enabled: true, text: { content: 'x' } });
expect(outsider.status).toBe(404);
expect(outsider.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
const anon = await request(cnBase)
.get(`/v1/albums/${albumId}/watermark-jobs`);
expect(anon.status).toBe(401);
const adminStatus = await getWatermark(adminToken);
expect(adminStatus.status).toBe(200);
const adminPost = await postWatermark(adminToken, {
enabled: true,
text: { content: 'admin v5' },
});
expect(adminPost.status).toBe(202);
expect(adminPost.body.configVersion).toBe(5);
});
it('⑩ 校验:enabled=true 缺 text.content / 越界值 / 非法 position → 400', async () => {
const cases: Array<Record<string, unknown>> = [
{ enabled: true }, // 缺 text.content
{ enabled: true, text: { content: '' } }, // 空串
{ enabled: true, text: { content: 'x' }, opacity: 101 },
{ enabled: true, text: { content: 'x' }, opacity: -1 },
{ enabled: true, text: { content: 'x' }, scalePercent: 4 },
{ enabled: true, text: { content: 'x' }, scalePercent: 51 },
{ enabled: true, text: { content: 'x' }, position: 'middle' },
{ enabled: true, text: { content: 'x', fontSize: 7 } },
{ enabled: true, text: { content: 'x', fontSize: 73 } },
{ enabled: 'yes', text: { content: 'x' } },
];
for (const body of cases) {
const res = await postWatermark(ownerToken, body);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('GET jobs 全史:创建时间倒序(configVersion 降序)且条目字段完整', async () => {
const status = await getWatermark(ownerToken);
expect(status.status).toBe(200);
const jobs = status.body.jobs as Array<{
jobId: string;
configVersion: number;
state: string;
progress: number;
total: number;
failed: number;
createdAt: string;
}>;
expect(jobs.length).toBeGreaterThanOrEqual(5);
const versions = jobs.map((j) => j.configVersion);
expect([...versions].sort((a, b) => b - a)).toEqual(versions);
for (const job of jobs) {
expect(Object.keys(job).sort()).toEqual(
['configVersion', 'createdAt', 'failed', 'jobId', 'progress', 'state', 'total'].sort(),
);
}
});
});