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:
浅念
2026-09-20 18:32:34 +08:00
parent 243b2f8721
commit 2bb6f291d3
25 changed files with 1622 additions and 41 deletions
+2 -1
View File
@@ -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 {}
+16 -29
View File
@@ -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,
};
}
+31
View File
@@ -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;
}
+460
View File
@@ -0,0 +1,460 @@
// services/api/test/downloads.e2e-spec.ts —— P4a Task 4 公开照片下载 e2e
// (T12 收口)。与 likes-realtime.e2e-spec 同模式:进程内起 cn + ov 两个真实
// 入口,相册/开关/上传全走 cn API(PATCH albums 开关 → album.updated 同步 →
// ov 副本生效),OutboxSender(生产实现)指向 ov 入口真实 URL。覆盖(A18):
// ① 开关矩阵:on/on → browse/original 双 200(variant 回显、expiresAt 未来、
// URL 可取回真实字节、original 字节 == 上传原图、browse 字节 == browse
// 变体对象);on/off → browse 200 / original 403 DOWNLOAD_DISABLED;
// off/off 与 off/on(级联——downloadEnabled 关则 original 也 403,无旁路)
// → 双 variant 均 403;
// ② 水印相册:browse 下载 URL = 模式键 variants/{photoId}/{v}/watermark.jpg
// (与公开列表同一 ovBrowseKey 推导),字节 == 水印变体对象;
// ③ 可见性:隐藏照片(cn 隐藏 + 同步)→ 404 PHOTO_NOT_PUBLIC;幽灵 token →
// 404 ALBUM_NOT_PUBLIC;幽灵照片 → 404 PHOTO_NOT_PUBLIC;variant 缺失/
// 非法 → 400 VALIDATION_FAILED;
// ④ 基础限速:DOWNLOAD_WINDOW_REQUESTS 压小(惰性 env)+ 清窗键 → 窗口超额
// 429 RATE_LIMITED;
// ⑤ 排除规则:全程下载调用后 visit_requests / album_stats 零行(下载不计数)。
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import { Readable } from 'node:stream';
import request from 'supertest';
import sharp from 'sharp';
import Redis from 'ioredis';
import { PrismaClient } from '@photo/db-cn';
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
import { ErrorCodes } from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import { AppCnModule } from '../src/app-cn.module';
import { AppOvModule } from '../src/app-ov.module';
import { seedAdmin } from '../src/scripts/seed-admin';
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
import { configureApp } from '../src/shared/configure-app';
import { PublishService } from '../../worker/src/publish.service';
import { WatermarkRegenService } from '../../worker/src/watermark.service';
import { resetDb } from './reset';
import { resetOvDb } from './reset-ov';
import { OutboxSender } from '../../sync/src/sender';
jest.setTimeout(180_000);
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
/** 测试内生成真实 JPEG(无二进制入库)。 */
async function makeJpeg(
width: number,
height: number,
rgb: { r: number; g: number; b: number },
): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: rgb } })
.jpeg({ quality: 85 })
.toBuffer();
}
interface SeededAlbum {
albumId: string;
token: string;
}
describe('downloads (e2e)', () => {
let cnApp: NestExpressApplication;
let ovApp: NestExpressApplication;
let cnBase: string;
let ovBase: string;
let prismaCn: PrismaClient;
let prismaOv: OvPrismaClient;
let storage: MinioStorageAdapter;
let sender: OutboxSender;
let adminToken: string;
/** 四开关组合相册 + 水印相册(各含已发布照片;on/on 另含隐藏目标)。 */
let onOn: SeededAlbum;
let onOff: SeededAlbum;
let offOff: SeededAlbum;
let offOn: SeededAlbum;
let watermarked: SeededAlbum;
let onOnPhoto: string;
let onOnHidden: string;
let onOffPhoto: string;
let offOffPhoto: string;
let offOnPhoto: string;
let wmPhoto: string;
/** 上传原图字节(original 下载对照基准)。 */
let onOnOriginal: Buffer;
/** 排空发送器:循环 runOnce 直至无未投递事件。 */
async function drainSender(): Promise<void> {
for (let round = 0; round < 20; round++) {
const stats = await sender.runOnce();
if (stats.picked === 0) {
return;
}
}
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
}
/** Redis ov 清限速窗键(跨 spec 残留 / 测试间重置计数)。 */
async function clearDownloadWindowKeys(): Promise<void> {
const redis = new Redis(
process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381',
);
try {
let cursor = '0';
do {
const [next, keys] = await redis.scan(
cursor,
'MATCH',
'download:window:*',
'COUNT',
1000,
);
cursor = next;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
} finally {
await redis.quit();
}
}
/** cn 建相册(缺省开关 true/false)→ 可选 PATCH 开关 → 返回 {albumId, token}。 */
async function createAlbum(
title: string,
switches?: { downloadEnabled?: boolean; originalDownloadEnabled?: boolean },
): Promise<SeededAlbum> {
const album = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${adminToken}`)
.send({
translations: [{ locale: 'zh-CN', title }],
defaultLocale: 'zh-CN',
startAt: '2026-12-01T10:00:00.000Z',
});
expect(album.status).toBe(201);
const seeded = {
albumId: album.body.id as string,
token: album.body.publicToken as string,
};
if (switches !== undefined) {
// 开关经 cn PATCH(album.updated 事件 → ov 副本;比直改副本行诚实)。
const patch = await request(cnBase)
.patch(`/v1/albums/${seeded.albumId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(switches);
expect(patch.status).toBe(200);
}
return seeded;
}
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
async function uploadAndPublish(
albumId: string,
clientTaskId: string,
jpeg: Buffer,
): Promise<string> {
const init = await request(cnBase)
.post('/v1/uploads')
.set('Authorization', `Bearer ${adminToken}`)
.send({
albumId,
clientTaskId,
filename: 'download.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
});
expect(init.status).toBe(201);
const { uploadId, photoId, credential } = init.body as {
uploadId: string;
photoId: string;
credential: { url: string; method: string; headers: Record<string, string> };
};
const put = await fetch(credential.url, {
method: credential.method,
headers: credential.headers,
body: new Uint8Array(jpeg),
});
expect(put.ok).toBe(true);
const done = await request(cnBase)
.post(`/v1/uploads/${uploadId}/complete`)
.set('Authorization', `Bearer ${adminToken}`);
expect(done.status).toBe(200);
expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe(
'published',
);
return photoId;
}
/** 下载 GET(返回 supertest Response)。 */
function download(
album: SeededAlbum,
photoId: string,
variant: string,
): Promise<request.Response> {
return request(ovBase).get(
`/v1/public/albums/${album.token}/photos/${photoId}/download?variant=${variant}`,
);
}
/** cn browse 变体对象字节(browse 下载对照基准)。 */
async function browseObjectOf(photoId: string): Promise<Buffer> {
const row = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId, variant: 'browse' } },
});
return readObject(row.objectKey);
}
/** 流式读取对象为单缓冲(与 worker readOriginal 同约定)。 */
async function readObject(key: string): Promise<Buffer> {
const stream = (await storage.getStream(key)) as Readable;
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
return Buffer.concat(chunks);
}
beforeAll(async () => {
expect(SYNC_KEY).not.toBe('');
await resetDb();
await resetOvDb();
await clearDownloadWindowKeys();
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
rawBody: true,
});
configureApp(cnApp);
await cnApp.listen(0);
cnBase = await cnApp.getUrl();
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
rawBody: true,
});
// main-ov 同形:契约帧适配器(WS 与 HTTP 同端口;本 spec 不用 WS,仅为
// 与生产入口同形的装配)。
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
configureApp(ovApp);
await ovApp.listen(0);
ovBase = await ovApp.getUrl();
prismaCn = new PrismaClient();
prismaOv = new OvPrismaClient();
storage = createStorageFromEnv();
sender = new OutboxSender({
prisma: prismaCn,
ovApiUrl: ovBase,
internalKey: SYNC_KEY,
});
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
const login = await request(cnBase).post('/v1/auth/password').send({
account: ADMIN_ACCOUNT,
password: ADMIN_PASSWORD,
});
expect(login.status).toBe(200);
adminToken = login.body.sessionToken as string;
// —— 开关矩阵四相册(缺省 true/false;PATCH 显式设定目标组合)——
onOn = await createAlbum('下载开原图开', {
originalDownloadEnabled: true,
});
onOff = await createAlbum('下载开原图关');
offOff = await createAlbum('下载关原图关', {
downloadEnabled: false,
originalDownloadEnabled: false,
});
offOn = await createAlbum('下载关原图开', {
downloadEnabled: false,
originalDownloadEnabled: true,
});
onOnOriginal = await makeJpeg(960, 640, { r: 190, g: 90, b: 40 });
onOnPhoto = await uploadAndPublish(onOn.albumId, 'dl-onon', onOnOriginal);
onOnHidden = await uploadAndPublish(
onOn.albumId,
'dl-onon-hidden',
await makeJpeg(800, 500, { r: 90, g: 190, b: 120 }),
);
onOffPhoto = await uploadAndPublish(
onOff.albumId,
'dl-onoff',
await makeJpeg(900, 600, { r: 60, g: 120, b: 190 }),
);
offOffPhoto = await uploadAndPublish(
offOff.albumId,
'dl-offoff',
await makeJpeg(700, 450, { r: 120, g: 60, b: 190 }),
);
offOnPhoto = await uploadAndPublish(
offOn.albumId,
'dl-offon',
await makeJpeg(850, 550, { r: 60, g: 190, b: 190 }),
);
// 隐藏目标:cn 隐藏 → photo.hidden(高优先级)→ ov 撤下。
const hide = await request(cnBase)
.patch(`/v1/photos/${onOnHidden}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ visibility: 'hidden' });
expect(hide.status).toBe(200);
// —— 水印相册:POST watermark-jobs(enabled)→ 直驱 done → ov 有效位 ——
watermarked = await createAlbum('下载水印相册');
wmPhoto = await uploadAndPublish(
watermarked.albumId,
'dl-wm',
await makeJpeg(900, 600, { r: 150, g: 150, b: 60 }),
);
const wmJob = await request(cnBase)
.post(`/v1/albums/${watermarked.albumId}/watermark-jobs`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ enabled: true, text: { content: '© Download e2e' } });
expect(wmJob.status).toBe(202);
expect(
await new WatermarkRegenService(prismaCn, storage).processJob(
wmJob.body.jobId as string,
),
).toBe('done');
await drainSender();
});
afterAll(async () => {
delete process.env.DOWNLOAD_WINDOW_REQUESTS;
await ovApp.close();
await cnApp.close();
await prismaCn.$disconnect();
await prismaOv.$disconnect();
});
it('① on/on:双 variant 200、variant/expiresAt 正确、URL 真实字节(original == 上传原图)', async () => {
const browse = await download(onOn, onOnPhoto, 'browse');
expect(browse.status).toBe(200);
expect(browse.body.variant).toBe('browse');
expect(Date.parse(browse.body.expiresAt)).toBeGreaterThan(Date.now());
// browse 键 = 内联 browse 键(无水印相册)且字节 == browse 变体对象。
expect(browse.body.url).toContain(
`/variants/${onOnPhoto}/1/browse.jpg`,
);
const browseBytes = Buffer.from(
await (await fetch(browse.body.url)).arrayBuffer(),
);
expect(browseBytes.length).toBeGreaterThan(0);
expect(browseBytes.equals(await browseObjectOf(onOnPhoto))).toBe(true);
const original = await download(onOn, onOnPhoto, 'original');
expect(original.status).toBe(200);
expect(original.body.variant).toBe('original');
expect(Date.parse(original.body.expiresAt)).toBeGreaterThan(Date.now());
expect(original.body.url).toContain(`/originals/${onOnPhoto}.jpg`);
// A20 语义的下载面:原图零触碰——字节与上传时完全一致。
const originalBytes = Buffer.from(
await (await fetch(original.body.url)).arrayBuffer(),
);
expect(originalBytes.equals(onOnOriginal)).toBe(true);
});
it('② on/off:browse 200;original 403 DOWNLOAD_DISABLED', async () => {
const browse = await download(onOff, onOffPhoto, 'browse');
expect(browse.status).toBe(200);
expect(browse.body.variant).toBe('browse');
expect((await fetch(browse.body.url)).ok).toBe(true);
const original = await download(onOff, onOffPhoto, 'original');
expect(original.status).toBe(403);
expect(original.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED);
});
it('③ off/off 与 off/on(级联):双 variant 均 403 DOWNLOAD_DISABLED', async () => {
for (const album of [offOff, offOn]) {
const photoId = album === offOff ? offOffPhoto : offOnPhoto;
const browse = await download(album, photoId, 'browse');
expect(browse.status).toBe(403);
expect(browse.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED);
// 级联:downloadEnabled 关 → original 无旁路(即使 originalDownloadEnabled 开)。
const original = await download(album, photoId, 'original');
expect(original.status).toBe(403);
expect(original.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED);
}
// ov 副本开关确已同步到位(矩阵前提非巧合)。
const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({
where: { albumId: offOn.albumId },
});
expect(ovAlbum.downloadEnabled).toBe(false);
expect(ovAlbum.originalDownloadEnabled).toBe(true);
});
it('④ 水印相册:browse 下载 = 模式键 watermark.jpg,字节 == 水印变体对象', async () => {
const res = await download(watermarked, wmPhoto, 'browse');
expect(res.status).toBe(200);
expect(res.body.url).toContain(
`/variants/${wmPhoto}/1/watermark.jpg`,
);
const bytes = Buffer.from(
await (await fetch(res.body.url)).arrayBuffer(),
);
const wmRow = await prismaCn.photo_variants.findUniqueOrThrow({
where: { photoId_variant: { photoId: wmPhoto, variant: 'watermark' } },
});
expect(bytes.equals(await readObject(wmRow.objectKey))).toBe(true);
// 与公开列表 browse 同键(同一 ovBrowseKey 推导的间接证明)。
const list = await request(ovBase)
.get(`/v1/public/albums/${watermarked.token}/photos?limit=10`);
expect(list.body.items[0].browse.url).toContain(
`/variants/${wmPhoto}/1/watermark.jpg`,
);
});
it('⑤ 可见性与参数校验:隐藏 404 / 幽灵 token 404 / 幽灵照片 404 / variant 非法 400', async () => {
const hidden = await download(onOn, onOnHidden, 'browse');
expect(hidden.status).toBe(404);
expect(hidden.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
const ghostAlbum = await request(ovBase).get(
`/v1/public/albums/${'g' + '0'.repeat(23)}/photos/${onOnPhoto}/download?variant=browse`,
);
expect(ghostAlbum.status).toBe(404);
expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
const ghostPhoto = await download(onOn, 'p-ghost', 'browse');
expect(ghostPhoto.status).toBe(404);
expect(ghostPhoto.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
for (const query of ['', '?variant=', '?variant=thumb', '?variant=1']) {
const res = await request(ovBase).get(
`/v1/public/albums/${onOn.token}/photos/${onOnPhoto}/download${query}`,
);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('⑥ 基础限速:同 IP 窗口超额 → 429 RATE_LIMITED', async () => {
// 清窗键重置计数(①-⑤ 已消耗本机 IP 预算)+ 压小上限。
await clearDownloadWindowKeys();
process.env.DOWNLOAD_WINDOW_REQUESTS = '3';
try {
for (let i = 0; i < 3; i++) {
const ok = await download(onOn, onOnPhoto, 'browse');
expect(ok.status).toBe(200);
}
const limited = await download(onOn, onOnPhoto, 'browse');
expect(limited.status).toBe(429);
expect(limited.body.code).toBe(ErrorCodes.RATE_LIMITED);
// 拒绝不改写已签发凭证语义:前序 URL 仍可取回(限速只挡新签发)。
} finally {
delete process.env.DOWNLOAD_WINDOW_REQUESTS;
await clearDownloadWindowKeys();
}
});
it('⑦ 排除规则:下载不计数——visit_requests / album_stats 全程零行', async () => {
expect(await prismaOv.visit_requests.count()).toBe(0);
expect(await prismaOv.album_stats.count()).toBe(0);
});
});
+3 -1
View File
@@ -455,12 +455,14 @@ describe('public (e2e)', () => {
expect(res.body.endAt).toBe('2026-10-01T12:00:00.000Z');
expect(Number.isNaN(Date.parse(res.body.startAt))).toBe(false);
expect(Number.isNaN(Date.parse(res.body.endAt))).toBe(false);
// 契约 PublicAlbum 必返字段全集(形状精确断言)。
// 契约 PublicAlbum 必返字段全集(形状精确断言;P4a Task 4 增下载开关)。
expect(Object.keys(res.body).sort()).toEqual([
'banner',
'categories',
'defaultLocale',
'downloadEnabled',
'endAt',
'originalDownloadEnabled',
'publicToken',
'resolvedLocale',
'startAt',