Files
photo-live/services/api/test/downloads.e2e-spec.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

461 lines
17 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/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);
});
});