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
+130
View File
@@ -0,0 +1,130 @@
import { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
flushSync,
seedAlbum,
seedPhoto,
} from './helpers';
// P4a Task 4 下载 Playwright(真实后端 + 真实签名管线):
// · 入口可见性随相册两级开关(off/off 无按钮 / on/off 单按钮 / on/on 分体
// 菜单两选项);
// · 微信内置浏览器 UA mock(addInitScript 覆写 navigator.userAgent)→ 点击
// 下载出引导浮层(不触端点、不 window.open),「知道了」收起;
// · 真实下载点击:download 端点 200 + window.open 收到签名 URL(对象键模式
// 断言;实际文件保存不在浏览器 e2e 域——由 api e2e 字节级覆盖)。
/** 种「下载相册」:1 张已发布照片,开关组合由参数给定。 */
async function seedDownloadAlbum(
alias: string,
switches: { downloadEnabled?: boolean; originalDownloadEnabled?: boolean },
): Promise<string> {
const album = await seedAlbum({
alias,
translations: [{ locale: 'zh-CN', title: `下载测试相册 ${alias}` }],
...switches,
});
await seedPhoto(album.id, `${alias}-p0`, {
width: 480,
height: 300,
publishedAt: new Date(Date.now() - 60_000),
});
await flushSync(album.id);
return album.publicToken;
}
async function openLightbox(page: Page, token: string, init?: () => void): Promise<void> {
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
if (init) await page.addInitScript(init);
await page.goto(`/a/${token}`);
await expect(page.locator('.cell').first()).toBeVisible();
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
}
test.afterAll(async () => {
await cleanupSeededAlbums();
});
test('download entry visibility follows album two-tier switches', async ({ page }) => {
test.setTimeout(120_000);
// off/off:无下载入口。
const offOff = await seedDownloadAlbum('dl-offoff', {
downloadEnabled: false,
originalDownloadEnabled: false,
});
await openLightbox(page, offOff);
await expect(page.locator('.download-group')).toHaveCount(0);
// on/off:单按钮(无 caret/菜单)。
const onOff = await seedDownloadAlbum('dl-onoff', {});
await openLightbox(page, onOff);
await expect(page.locator('.control.download')).toBeVisible();
await expect(page.locator('.control.download-caret')).toHaveCount(0);
// on/on:分体按钮,菜单两选项(下载图片 / 下载原图)。
const onOn = await seedDownloadAlbum('dl-onon', { originalDownloadEnabled: true });
await openLightbox(page, onOn);
await expect(page.locator('.control.download-caret')).toBeVisible();
await page.locator('.control.download-caret').click();
await expect(page.locator('.download-menu button')).toHaveText(['下载图片', '下载原图']);
});
test('WeChat webview UA shows guidance overlay instead of downloading', async ({ page }) => {
test.setTimeout(120_000);
const token = await seedDownloadAlbum('dl-wechat', {});
// UA 覆写先于应用代码(addInitScript);MicroMessenger 触发引导浮层面。
await openLightbox(page, token, () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.49(0x18003133) NetType/WIFI Language/zh_CN';
Object.defineProperty(window.navigator, 'userAgent', { get: () => ua, configurable: true });
});
let endpointHit = false;
page.on('request', (request) => {
if (request.url().includes('/download?')) endpointHit = true;
});
await page.locator('.control.download').click();
await expect(page.locator('.wechat-guide[role="dialog"]')).toBeVisible();
await expect(page.locator('.wechat-guide h2')).toHaveText('保存图片指引');
await expect(page.locator('.guide-copy')).toBeVisible();
// 「知道了」收起浮层;期间 download 端点零命中(引导替代直接下载)。
await page.locator('.guide-dismiss').click();
await expect(page.locator('.wechat-guide')).toHaveCount(0);
expect(endpointHit).toBe(false);
});
test('real click downloads browse via signed url from the endpoint', async ({ page }) => {
test.setTimeout(120_000);
const token = await seedDownloadAlbum('dl-real', {});
// 捕获 window.open(避免真实弹出标签页):记录 URL 供对象键断言。
await openLightbox(page, token, () => {
(window as unknown as { __openedUrls: string[] }).__openedUrls = [];
window.open = ((url: string | URL) => {
(window as unknown as { __openedUrls: string[] }).__openedUrls.push(String(url));
return {} as Window;
}) as typeof window.open;
});
const endpoint = page.waitForResponse(
(response) =>
response.url().includes('/download?variant=browse') && response.status() === 200,
);
await page.locator('.control.download').click();
const response = await endpoint;
expect((await response.json()).variant).toBe('browse');
await expect
.poll(async () =>
page.evaluate(() => (window as unknown as { __openedUrls: string[] }).__openedUrls[0] ?? ''),
)
.toContain('/variants/');
const opened = await page.evaluate(
() => (window as unknown as { __openedUrls: string[] }).__openedUrls[0] ?? '',
);
// 短期签名凭证(SigV4 查询参数面)+ browse 对象键(无水印相册)。
expect(opened).toContain('X-Amz-Signature');
expect(opened).toContain('/browse.jpg');
});
+5
View File
@@ -53,6 +53,9 @@ export interface SeedAlbumOptions {
}>;
status?: string;
publicEnabled?: boolean;
/** 下载两级开关(P4a Task 4):缺省库默认 true/false(同 D06 工作默认)。 */
downloadEnabled?: boolean;
originalDownloadEnabled?: boolean;
timezone?: string;
startAt?: Date;
endAt?: Date | null;
@@ -72,6 +75,8 @@ export async function seedAlbum(options: SeedAlbumOptions): Promise<{ id: string
status: options.status ?? 'active',
defaultLocale: options.defaultLocale ?? 'zh-CN',
publicEnabled: options.publicEnabled ?? true,
downloadEnabled: options.downloadEnabled ?? true,
originalDownloadEnabled: options.originalDownloadEnabled ?? false,
},
select: { id: true, publicToken: true },
});