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