Files
photo-live/services/api/src/interactions/downloads.service.ts
T
浅念 2bb6f291d3 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>
2026-09-20 18:32:34 +08:00

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