Files
photo-live/services/api/src/albums/watermark.service.ts
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:13:08 +08:00

260 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, status: true },
});
if (album === null || album.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
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(),
})),
};
}
}