2bb6f291d3
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>
461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
// 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);
|
||
});
|
||
});
|