158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
|
|
// 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();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|