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