// 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 { // ① 相册/照片公开校验(同 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 { 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(); } } }