feat(ov,viewer): two-tier download with wechat guidance
P4a Task 4(T12 收口):ov 域公开照片下载端点 + viewer 下载 UI。
- interactions 增 downloads:GET /public/albums/{token}/photos/{photoId}
/download?variant=browse|original。两级开关(A18):browse 需
downloadEnabled;original 需叠加 originalDownloadEnabled(级联,browse 关
则两者均 403 DOWNLOAD_DISABLED)。水印相册 browse 现签 watermark pattern
key(browse-key 解析抽取 shared/ov-browse-key 与 public-ov 复用);原图
现签 originalObjectKey(SIGNED_URL_TTL)。来源 IP 固定窗限速
(DOWNLOAD_WINDOW_REQUESTS/DOWNLOAD_WINDOW_SECONDS,env 可调);零浏览
计数写入(排除规则)。STORAGE_PORT_OV 经新 StorageOvModule 与 public-ov
共享同一实例。
- 契约微调登记:PublicAlbum 增 readOnly 必返 downloadEnabled/
originalDownloadEnabled(cn+ov 同形返回;public e2e 精确键断言同步)。
- viewer:lightbox 下载入口随开关渲染(无/单按钮/分体菜单两选项)、过期
凭证静默重取一次、微信内置浏览器(MicroMessenger UA)出保存图片指引
浮层(复制深链 + 反馈)替代直接下载;i18n zh/en 词条。
- tests:api e2e +7(开关矩阵 + URL/字节级断言、水印键一致、隐藏 404、
限速 429、零计数行);viewer vitest +9(UA/矩阵/菜单/重取/失败/指引/
复制);viewer Playwright +3(入口矩阵、微信 UA 浮层、真实端点 200 +
签名 URL 模式)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
// 分发见 main-ov 的 ContractWsAdapter),EventEmitter2 全局总线承接「事务提交
|
||||
// → 实时广播」(ov-events.ts,单进程——多实例 P5);Task 8 起 interactions
|
||||
// 增浏览上报(visits:窗口去重 + requestId 幂等)并加入 cleanup(互动流水
|
||||
// 超窗口行回收调度)。cn 模块保持原样,两入口互不影响。
|
||||
// 超窗口行回收调度);P4a Task 4 起 interactions 增公开照片下载(downloads:
|
||||
// 两级开关 + 水印模式键 browse + 原图现签凭证)。cn 模块保持原样,两入口互不影响。
|
||||
import {
|
||||
type MiddlewareConsumer,
|
||||
Module,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// services/api/src/interactions/downloads.controller.ts —— 公开照片下载端点
|
||||
// (P4a Task 4,ov 域)。路由与契约 downloadPublicPhoto 一致(tags
|
||||
// [interactions],路径前缀不变、部署域名区分);匿名(无 SessionGuard)。
|
||||
// IP 取 req.ip 并归一 IPv6 映射前缀(同 likes.controller,仅作限速键)。
|
||||
// variant 查询参数手动解析(同 public-ov parsePublicPhotoSort 口径):
|
||||
// 缺失/非法 → 400 VALIDATION_FAILED(契约 DownloadVariant 枚举外无静默缺省
|
||||
// ——下载是显式动作,不允许「缺省猜测版本」)。
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Ip,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { validationFailed } from '../albums/support';
|
||||
import {
|
||||
type DownloadVariant,
|
||||
type PublicPhotoDownloadResponse,
|
||||
DownloadsService,
|
||||
} from './downloads.service';
|
||||
|
||||
/** variant 查询参数解析:仅 browse|original;其余一律 400。 */
|
||||
export function parseDownloadVariant(raw: string | undefined): DownloadVariant {
|
||||
if (raw === 'browse' || raw === 'original') {
|
||||
return raw;
|
||||
}
|
||||
throw validationFailed('variant 必须是 browse 或 original');
|
||||
}
|
||||
|
||||
@Controller('public/albums')
|
||||
export class DownloadsController {
|
||||
constructor(private readonly downloads: DownloadsService) {}
|
||||
|
||||
@Get(':token/photos/:photoId/download')
|
||||
download(
|
||||
@Param('token') token: string,
|
||||
@Param('photoId') photoId: string,
|
||||
@Query('variant') variant: string | undefined,
|
||||
@Ip() ip: string | undefined,
|
||||
): Promise<PublicPhotoDownloadResponse> {
|
||||
const clientIp = ip === undefined ? undefined : ip.replace(/^::ffff:/, '');
|
||||
return this.downloads.download(
|
||||
token,
|
||||
photoId,
|
||||
parseDownloadVariant(variant),
|
||||
clientIp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// services/api/src/interactions/downloads.service.ts —— 公开照片下载(P4a
|
||||
// Task 4,ov 域)。契约 GET /public/albums/{token}/photos/{photoId}/download
|
||||
// ?variant=browse|original(downloadPublicPhoto,T12 收口)。全流程:
|
||||
//
|
||||
// ① 校验(同 likes/public-ov 读路径口径):相册 publicToken → albums_ov +
|
||||
// isAlbumVisibleOv(含紧急停用)→ 统一 404 ALBUM_NOT_PUBLIC;照片不存在/
|
||||
// 不属于该册/非 published → 404 PHOTO_NOT_PUBLIC;variant 取值非法 →
|
||||
// 400 VALIDATION_FAILED(controller 解析)。
|
||||
// ② 基础限速:来源 IP 固定窗(Redis ov INCR + TTL,DOWNLOAD_WINDOW_REQUESTS/
|
||||
// DOWNLOAD_WINDOW_SECONDS)超限 429 RATE_LIMITED(拒绝也消耗预算——固定窗
|
||||
// 惯例;无 sessionId/幂等面可借键,纯 IP 键)。NAT 出口共享:上限宽于单
|
||||
// 用户直觉点击量(120/分),定位是「误客户端缓冲」,真实防护归 P5 速率层。
|
||||
// ③ 两级开关校验(A18):browse 需 downloadEnabled;original 需
|
||||
// downloadEnabled **且** originalDownloadEnabled(更高级开关联联)。
|
||||
// 对应级别关闭 → 403 DOWNLOAD_DISABLED(browse 级关闭时 original 也 403
|
||||
// ——级联语义,不给「关浏览却开原图」的旁路)。
|
||||
// ④ 键解析:browse = ovBrowseKey(shared 单一实现:水印开启且有效版本 > 0 →
|
||||
// 模式键 watermark.jpg,与公开列表/单图同一对象);original =
|
||||
// photos_ov.originalObjectKey(原图零触碰水印链——A20)。
|
||||
// ⑤ 现签短期 GET 凭证(SIGNED_URL_TTL_SECONDS,SigV4 本地计算)→
|
||||
// {url, expiresAt, variant}。
|
||||
//
|
||||
// 排除规则(契约 description 落地):本端点**不**做浏览计数——不写 visit_* /
|
||||
// album_stats / outbox_stat_events(e2e 断言行数不变;下载不是浏览)。
|
||||
// 日志合规:响应体签名 URL 不入日志(pino 默认只记请求行;本服务亦不显式
|
||||
// 打印 url——契约「禁止持久保存/写入分享链接」的服务端面)。
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
DOWNLOAD_WINDOW_REQUESTS,
|
||||
DOWNLOAD_WINDOW_SECONDS,
|
||||
ErrorCodes,
|
||||
} from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { albumNotPublic, photoNotPublic } from '../public/support';
|
||||
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
|
||||
import { isAlbumVisibleOv } from '../shared/album-visibility';
|
||||
import { ovBrowseKey } from '../shared/ov-browse-key';
|
||||
import { OvRedisService } from '../redis-ov/ov-redis.module';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import { STORAGE_PORT_OV } from '../public-ov/storage-ov.provider';
|
||||
|
||||
export type PublicPhotoDownloadResponse =
|
||||
components['schemas']['PublicPhotoDownload'];
|
||||
|
||||
export type DownloadVariant = 'browse' | 'original';
|
||||
|
||||
/** 单 IP 固定窗请求数上限(惰性读 env,同 visitWindowRequests 模式)。 */
|
||||
export function downloadWindowRequests(): number {
|
||||
const parsed = Number.parseInt(process.env.DOWNLOAD_WINDOW_REQUESTS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0
|
||||
? parsed
|
||||
: DOWNLOAD_WINDOW_REQUESTS;
|
||||
}
|
||||
|
||||
function downloadDisabled(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.DOWNLOAD_DISABLED,
|
||||
'该相册未开放下载',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
function rateLimited(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.RATE_LIMITED,
|
||||
'下载过于频繁,请稍后再试',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DownloadsService {
|
||||
constructor(
|
||||
private readonly prisma: OvPrismaService,
|
||||
private readonly redis: OvRedisService,
|
||||
@Inject(STORAGE_PORT_OV) private readonly storage: StoragePort,
|
||||
) {}
|
||||
|
||||
async download(
|
||||
token: string,
|
||||
photoId: string,
|
||||
variant: DownloadVariant,
|
||||
clientIp: string | undefined,
|
||||
): Promise<PublicPhotoDownloadResponse> {
|
||||
// ① 相册/照片公开校验(同 likes ① 口径)。
|
||||
const album = await this.prisma.albums_ov.findUnique({
|
||||
where: { publicToken: token },
|
||||
});
|
||||
if (album === null || !isAlbumVisibleOv(album)) {
|
||||
throw albumNotPublic();
|
||||
}
|
||||
const photo = await this.prisma.photos_ov.findUnique({
|
||||
where: { photoId },
|
||||
});
|
||||
if (
|
||||
photo === null ||
|
||||
photo.albumId !== album.albumId ||
|
||||
photo.visibility !== 'published'
|
||||
) {
|
||||
throw photoNotPublic();
|
||||
}
|
||||
|
||||
// ② 基础限速(通过后才现签;拒绝也消耗预算——固定窗惯例)。
|
||||
await this.assertRateLimit(clientIp);
|
||||
|
||||
// ③ 两级开关(A18):browse 级关闭 → 两 variant 均 403(级联,无旁路)。
|
||||
if (!album.downloadEnabled) {
|
||||
throw downloadDisabled();
|
||||
}
|
||||
if (variant === 'original' && !album.originalDownloadEnabled) {
|
||||
throw downloadDisabled();
|
||||
}
|
||||
|
||||
// ④ 键解析:browse 复用公开读同一推导(水印模式键一致性);original 原图键。
|
||||
const objectKey =
|
||||
variant === 'browse'
|
||||
? ovBrowseKey(
|
||||
{
|
||||
albumId: album.albumId,
|
||||
enabled: album.watermarkEnabled,
|
||||
configVersion: album.watermarkConfigVersion,
|
||||
},
|
||||
photo.photoId,
|
||||
photo.browseObjectKey,
|
||||
)
|
||||
: photo.originalObjectKey;
|
||||
|
||||
// ⑤ 现签短期凭证(ov 只读端口;SigV4 本地计算无网络副作用)。
|
||||
const ttl = signedUrlTtlSeconds();
|
||||
const url = await this.storage.presignGet(objectKey, ttl);
|
||||
return {
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||
variant,
|
||||
};
|
||||
}
|
||||
|
||||
/** 固定窗限速:Redis ov INCR 计请求数,首次写键挂窗口 TTL(同 visits ③)。 */
|
||||
private async assertRateLimit(clientIp: string | undefined): Promise<void> {
|
||||
if (clientIp === undefined || clientIp === '') {
|
||||
return; // IP 不可得(本地直连测试面)→ 不限速,与 likes IP 键缺席语义一致
|
||||
}
|
||||
const key = `download:window:ip:${clientIp}`;
|
||||
const counted = await this.redis.incr(key);
|
||||
if (counted === 1) {
|
||||
// 新键(本窗首次):挂 TTL;EXPIRE 失败不影响本轮判定。
|
||||
await this.redis
|
||||
.expire(key, DOWNLOAD_WINDOW_SECONDS)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (counted > downloadWindowRequests()) {
|
||||
throw rateLimited();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
// services/api/src/interactions/ —— ov 公开互动模块(P2 Task 7 起)。
|
||||
// 仅 ov 入口装配(app-ov.module 显式挑选;cn 入口不感知)。Task 7 装配
|
||||
// 点赞批次端点;Task 8 装配浏览上报(visits.service/controller)。
|
||||
// 点赞批次端点;Task 8 装配浏览上报(visits.service/controller);P4a Task 4
|
||||
// 装配公开照片下载(downloads.service/controller——两级开关 + 现签凭证,
|
||||
// STORAGE_PORT_OV 经 StorageOvModule 共享 public-ov 同一实例)。
|
||||
// 事件广播经 EventEmitter2(app 根模块 EventEmitterModule.forRoot()
|
||||
// 全局注册)发往 realtime 网关(likes.burst / stats.updated),无模块间
|
||||
// 直接依赖。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
|
||||
import { OvRedisModule } from '../redis-ov/ov-redis.module';
|
||||
import { StorageOvModule } from '../public-ov/storage-ov.module';
|
||||
import { DownloadsController } from './downloads.controller';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
import { LikesController } from './likes.controller';
|
||||
import { LikesService } from './likes.service';
|
||||
import { VisitsController } from './visits.controller';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@Module({
|
||||
imports: [OvPrismaModule, OvRedisModule],
|
||||
controllers: [LikesController, VisitsController],
|
||||
providers: [LikesService, VisitsService],
|
||||
imports: [OvPrismaModule, OvRedisModule, StorageOvModule],
|
||||
controllers: [LikesController, VisitsController, DownloadsController],
|
||||
providers: [LikesService, VisitsService, DownloadsService],
|
||||
})
|
||||
export class InteractionsModule {}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// services/api/src/public-ov/public-ov.module.ts —— ov 公开(观众端)读模块
|
||||
// (P2 Task 6)。仅 ov 入口装配(app-ov.module 显式挑选;cn 的 PublicModule
|
||||
// 互不影响)。装配:PublicOvController/PublicOvService + OvPrismaModule
|
||||
// (非 @Global,显式 import)+ STORAGE_PORT_OV(ov 只读存储约定,见
|
||||
// storage-ov.provider 头注)。无认证语义(公开端点匿名)。
|
||||
// (非 @Global,显式 import)+ StorageOvModule(STORAGE_PORT_OV,ov 只读存储
|
||||
// 约定见 storage-ov.provider 头注;P4a Task 4 起独立模块与 interactions 共享
|
||||
// 同一实例)。无认证语义(公开端点匿名)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
|
||||
import { PublicOvController } from './public-ov.controller';
|
||||
import { PublicOvService } from './public-ov.service';
|
||||
import { storageOvProvider } from './storage-ov.provider';
|
||||
import { StorageOvModule } from './storage-ov.module';
|
||||
|
||||
@Module({
|
||||
imports: [OvPrismaModule],
|
||||
imports: [OvPrismaModule, StorageOvModule],
|
||||
controllers: [PublicOvController],
|
||||
providers: [PublicOvService, storageOvProvider],
|
||||
providers: [PublicOvService],
|
||||
})
|
||||
export class PublicOvModule {}
|
||||
|
||||
@@ -38,8 +38,8 @@ import {
|
||||
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import { albumNotPublic, photoNotPublic } from '../public/support';
|
||||
import { ovBrowseKey, type WatermarkContextOv } from '../shared/ov-browse-key';
|
||||
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'];
|
||||
@@ -84,12 +84,8 @@ interface HotSnapshot {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** 相册级水印服务上下文(albums_ov 副本位 + 归属 albumId,见 ovBrowseKey)。 */
|
||||
interface WatermarkContext {
|
||||
albumId: string;
|
||||
enabled: boolean;
|
||||
configVersion: number;
|
||||
}
|
||||
/** 相册级水印服务上下文(shared/ov-browse-key 单一实现;P4a Task 4 起下载端点共用)。 */
|
||||
type WatermarkContext = WatermarkContextOv;
|
||||
|
||||
/**
|
||||
* 热门快照 offset 游标:base64url JSON 数字(快照内下一页起始下标)。快照在
|
||||
@@ -183,6 +179,10 @@ export class PublicOvService {
|
||||
banner,
|
||||
// 真实值(Task 8 的 /visits 端点维护计数;此处只读列,无行 = 0)。
|
||||
viewCount: stats?.viewCount ?? 0,
|
||||
// 下载两级开关(P4a Task 4):viewer 按此渲染下载入口(仅 ov 域消费,
|
||||
// cn 同形返回——契约 PublicAlbum 必返字段)。
|
||||
downloadEnabled: album.downloadEnabled,
|
||||
originalDownloadEnabled: album.originalDownloadEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ export class PublicOvService {
|
||||
height: photo.height,
|
||||
thumb: await this.presign(photo.thumbObjectKey),
|
||||
browse: await this.presign(
|
||||
this.ovBrowseKey(
|
||||
ovBrowseKey(
|
||||
this.watermarkContext(album),
|
||||
photo.photoId,
|
||||
photo.browseObjectKey,
|
||||
@@ -383,7 +383,7 @@ export class PublicOvService {
|
||||
height: photo.height,
|
||||
thumb: await this.presign(photo.thumbObjectKey),
|
||||
browse: await this.presign(
|
||||
this.ovBrowseKey(watermark, photo.photoId, photo.browseObjectKey),
|
||||
ovBrowseKey(watermark, photo.photoId, photo.browseObjectKey),
|
||||
),
|
||||
likeCount: photo.likeTotal,
|
||||
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
||||
@@ -406,27 +406,14 @@ export class PublicOvService {
|
||||
}
|
||||
|
||||
/**
|
||||
* ov 浏览图对象键(P4a Task 3):水印开启时用**模式键**——
|
||||
* variants/{photoId}/{watermarkConfigVersion}/watermark.jpg(同一对象空间,
|
||||
* cn worker 直写;ov 无变体副本表,键由 cn 行结构确定)。副本位携带的是
|
||||
* **有效版本**(albums.watermarkEffectiveVersion,仅 done 任务推进):该版本
|
||||
* 键的存在性由重生成任务的收尾不变量保证(每张已发布照片处理成功或回退拷贝
|
||||
* 旧图到目标键——失败照片保留旧图,A20),disable 版任务完成后 enabled=false
|
||||
* 回落 browse 内联键(水印键对象保留,历史可溯)。
|
||||
* ov 浏览图对象键(P4a Task 3,Task 4 起为 shared 单一实现):水印开启时用
|
||||
* **模式键**——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) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// services/api/src/public-ov/storage-ov.module.ts —— STORAGE_PORT_OV 的模块化
|
||||
// 装配(P4a Task 4)。与 OvPrismaModule/OvRedisModule 同款约定:非 @Global、
|
||||
// provide + export,ov 角色模块(public-ov / interactions)显式 import 共享
|
||||
// **同一适配器实例**——此前 provider 直挂 PublicOvModule,下载端点(interactions
|
||||
// 域)也需要现签 GET 凭证,收敛到独立模块避免各持一份 S3 客户端。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { storageOvProvider, STORAGE_PORT_OV } from './storage-ov.provider';
|
||||
|
||||
@Module({
|
||||
providers: [storageOvProvider],
|
||||
exports: [STORAGE_PORT_OV],
|
||||
})
|
||||
export class StorageOvModule {}
|
||||
@@ -145,6 +145,10 @@ export class PublicService {
|
||||
banner,
|
||||
// P1a 占位:浏览计数由后续 /visits 端点提供(技术实施方案 §7.1)。
|
||||
viewCount: null,
|
||||
// 下载两级开关(P4a Task 4 契约 PublicAlbum 新增必返字段):cn 同形返回
|
||||
// (下载端点本身仅 ov 域装配——观众动作端点,见 interactions 模块)。
|
||||
downloadEnabled: album.downloadEnabled,
|
||||
originalDownloadEnabled: album.originalDownloadEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// services/api/src/shared/ov-browse-key.ts —— ov 侧浏览图对象键的单一推导
|
||||
// 实现(P4a Task 4 自 public-ov.service 抽出):公开读(列表/单图 browse 现签)
|
||||
// 与下载端点(variant=browse 的下载键)必须落同一对象——水印开启时两者都是
|
||||
// 模式键(watermarkVariantObjectKey),关闭时都是 photos_ov 内联 browse 键。
|
||||
// 键由 (albumId, photoId, 有效版本) 确定性构成(ov 无变体副本表);对象存在性
|
||||
// 由 cn 侧 done 任务收尾不变量保证(见 packages/contracts/src/watermark.ts 与
|
||||
// public-ov.service 头注)。
|
||||
import { watermarkVariantObjectKey } from '@photo/storage';
|
||||
|
||||
/** 相册级水印服务上下文(albums_ov 副本位 + 归属 albumId)。 */
|
||||
export interface WatermarkContextOv {
|
||||
albumId: string;
|
||||
enabled: boolean;
|
||||
configVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ov 浏览图对象键:水印开启(enabled && 有效版本 > 0)→ 模式键
|
||||
* variants/{photoId}/{有效版本}/watermark.jpg(cn worker 直写同一对象空间,
|
||||
* 失败照片由任务收尾回退拷贝旧图——键恒有对象);否则 → photos_ov 内联
|
||||
* browse 键(disable 版任务完成后回落,水印键对象保留历史可溯)。
|
||||
*/
|
||||
export function ovBrowseKey(
|
||||
watermark: WatermarkContextOv,
|
||||
photoId: string,
|
||||
browseObjectKey: string,
|
||||
): string {
|
||||
return watermark.enabled && watermark.configVersion > 0
|
||||
? watermarkVariantObjectKey(watermark.albumId, photoId, watermark.configVersion)
|
||||
: browseObjectKey;
|
||||
}
|
||||
Reference in New Issue
Block a user