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:
@@ -59,4 +59,7 @@ VISIT_REQUEST_TTL_MINUTES=35
|
||||
# 每窗请求数上限——去重本身为主缓解)与 ov 互动流水清理调度间隔(分钟,0=停用;
|
||||
# 清理 like_batches/visit_requests/visit_sessions 超窗口行,累计总数永不清)。
|
||||
VISIT_WINDOW_REQUESTS=60
|
||||
# P4a Task 4:公开照片下载端点基础限速(来源 IP 固定窗,窗口=60s 固定;
|
||||
# 每窗请求数上限——签名现算无幂等面,限速定位是误客户端缓冲,P5 速率层接防)。
|
||||
DOWNLOAD_WINDOW_REQUESTS=120
|
||||
CLEANUP_INTERVAL_MINUTES=60
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getPhotoDownload,
|
||||
getPublicPhotos,
|
||||
type PublicAlbum,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoDownload,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
import { usePhotoSequence } from '../feed/sequence';
|
||||
import {
|
||||
isExpired,
|
||||
isWechatWebview,
|
||||
useDownload,
|
||||
type DownloadController,
|
||||
} from '../download/useDownload';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// P4a Task 4 下载(T12 收口):开关→入口可见性矩阵、分体菜单选项、微信 UA
|
||||
// 判定与引导浮层、过期凭证重取一次、失败提示、复制深链。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
getPublicPhotos: vi.fn(),
|
||||
getPublicPhoto: vi.fn(),
|
||||
getPhotoDownload: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
const mockedGetPhotoDownload = vi.mocked(getPhotoDownload);
|
||||
|
||||
const WECHAT_IOS_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';
|
||||
const WECHAT_ANDROID_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/122.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49.2500(0x28004935)';
|
||||
const WECHAT_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x670c0123) WindowsWechat(0x63090a13)';
|
||||
const PLAIN_IOS_SAFARI_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
const DESKTOP_CHROME_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
||||
|
||||
function makePhoto(id: string): PublicPhoto {
|
||||
return {
|
||||
photoId: id,
|
||||
width: 800,
|
||||
height: 500,
|
||||
thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-10-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeList(items: PublicPhoto[]): PublicPhotoList {
|
||||
return { items, nextCursor: null, hasMore: false };
|
||||
}
|
||||
|
||||
function credential(url: string, expiresAt: string, variant: 'browse' | 'original'): PublicPhotoDownload {
|
||||
return { url, expiresAt, variant };
|
||||
}
|
||||
|
||||
const FUTURE = new Date(Date.now() + 14 * 60_000).toISOString();
|
||||
const PAST = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
function stubUserAgent(ua: string): void {
|
||||
Object.defineProperty(window.navigator, 'userAgent', { value: ua, configurable: true });
|
||||
}
|
||||
|
||||
function restoreUserAgent(): void {
|
||||
// 删除实例自有属性,回落原型 getter(jsdom 原始 UA)。
|
||||
delete (window.navigator as { userAgent?: string }).userAgent;
|
||||
}
|
||||
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
|
||||
function spyWindowOpen() {
|
||||
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
}
|
||||
let openSpy: ReturnType<typeof spyWindowOpen>;
|
||||
|
||||
/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */
|
||||
interface HostVm {
|
||||
albumRef: PublicAlbum | null;
|
||||
download: DownloadController;
|
||||
}
|
||||
|
||||
/** 宿主:sequence + 相册 ref + 下载控制器 + lightbox(镜像 ActivityPage 组装)。 */
|
||||
function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
|
||||
const Host = defineComponent({
|
||||
components: { PhotoLightbox },
|
||||
setup() {
|
||||
const sequence = usePhotoSequence({
|
||||
token: ref('tok-dl'),
|
||||
locale: ref('zh-CN'),
|
||||
category: ref(null),
|
||||
});
|
||||
const albumRef = ref<PublicAlbum | null>(album);
|
||||
const current = ref('p1');
|
||||
const download = useDownload({
|
||||
token: ref('tok-dl'),
|
||||
album: () => albumRef.value,
|
||||
photoId: () => current.value,
|
||||
});
|
||||
return { sequence, photoId: current, download, albumRef };
|
||||
},
|
||||
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
|
||||
});
|
||||
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return wrapper as VueWrapper & { vm: HostVm };
|
||||
}
|
||||
|
||||
describe('photo download (P4a Task 4)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')]));
|
||||
mockedGetPhotoDownload.mockReset();
|
||||
openSpy = spyWindowOpen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
restoreUserAgent();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('detects WeChat webview UAs (iOS/Android/Windows) and passes others', () => {
|
||||
for (const ua of [WECHAT_IOS_UA, WECHAT_ANDROID_UA, WECHAT_WINDOWS_UA]) {
|
||||
expect(isWechatWebview(ua)).toBe(true);
|
||||
}
|
||||
for (const ua of [PLAIN_IOS_SAFARI_UA, DESKTOP_CHROME_UA, '']) {
|
||||
expect(isWechatWebview(ua)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats expiresAt at-or-before now as expired', () => {
|
||||
expect(isExpired(PAST)).toBe(true);
|
||||
expect(isExpired(new Date().toISOString())).toBe(true);
|
||||
expect(isExpired(FUTURE)).toBe(false);
|
||||
expect(isExpired('not-a-date')).toBe(false); // 非法时间戳不误判过期
|
||||
});
|
||||
|
||||
it('controller model: switch matrix incl. off/on cascade (original implies browse)', async () => {
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
const controller = wrapper.vm.download;
|
||||
|
||||
// on/off(夹具缺省)。
|
||||
expect(controller.canDownload).toBe(true);
|
||||
expect(controller.canDownloadOriginal).toBe(false);
|
||||
|
||||
const cases: Array<[boolean, boolean, boolean, boolean]> = [
|
||||
// [downloadEnabled, originalDownloadEnabled, canDownload, canDownloadOriginal]
|
||||
[true, true, true, true],
|
||||
[true, false, true, false],
|
||||
[false, false, false, false],
|
||||
[false, true, false, false], // 级联:browse 关则 original 项不出现
|
||||
];
|
||||
for (const [downloadEnabled, originalDownloadEnabled, can, canOriginal] of cases) {
|
||||
wrapper.vm.albumRef = makeAlbum({ downloadEnabled, originalDownloadEnabled });
|
||||
await flushPromises();
|
||||
expect(controller.canDownload).toBe(can);
|
||||
expect(controller.canDownloadOriginal).toBe(canOriginal);
|
||||
}
|
||||
});
|
||||
|
||||
it('toolbar visibility: off/on → no entry; on/off → single button; on/on → split', async () => {
|
||||
// off/off:无下载入口。
|
||||
const off = mountLightbox(makeAlbum({ downloadEnabled: false, originalDownloadEnabled: false }));
|
||||
await flushPromises();
|
||||
expect(off.find('.download-group').exists()).toBe(false);
|
||||
|
||||
// on/off:单按钮,无 caret/菜单。
|
||||
const browseOnly = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
expect(browseOnly.find('.control.download').exists()).toBe(true);
|
||||
expect(browseOnly.find('.control.download-caret').exists()).toBe(false);
|
||||
|
||||
// on/on:分体按钮(caret 出现,菜单默认收起)。
|
||||
const split = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
|
||||
await flushPromises();
|
||||
expect(split.find('.control.download-caret').exists()).toBe(true);
|
||||
expect(split.find('.download-menu').exists()).toBe(false);
|
||||
// 主按钮 aria 展开 = false。
|
||||
expect(split.find('.control.download-caret').attributes('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('split menu offers both variants; picking original requests the original endpoint', async () => {
|
||||
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-original.jpg', FUTURE, 'original'));
|
||||
const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download-caret').trigger('click');
|
||||
const items = wrapper.findAll('.download-menu button');
|
||||
expect(items.map((item) => item.text())).toEqual(['下载图片', '下载原图']);
|
||||
|
||||
await items[1]!.trigger('click');
|
||||
await flushPromises();
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'original');
|
||||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-original.jpg', '_blank', 'noopener,noreferrer');
|
||||
// 选择后菜单收起。
|
||||
expect(wrapper.find('.download-menu').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('main button downloads browse; expired credential is re-requested exactly once', async () => {
|
||||
// 首响应已过期(缓存/时钟漂移)→ 重取一次 → 打开第二个 URL。
|
||||
mockedGetPhotoDownload
|
||||
.mockResolvedValueOnce(credential('http://img.test/p1-browse-stale.jpg', PAST, 'browse'))
|
||||
.mockResolvedValueOnce(credential('http://img.test/p1-browse-fresh.jpg', FUTURE, 'browse'));
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2);
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse');
|
||||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse-fresh.jpg', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('fresh credential opens directly without a second request; failure shows a retryable notice', async () => {
|
||||
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-browse.jpg', FUTURE, 'browse'));
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse.jpg', '_blank', 'noopener,noreferrer');
|
||||
expect(wrapper.find('.download-failed').exists()).toBe(false);
|
||||
|
||||
// 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。
|
||||
openSpy.mockClear();
|
||||
mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom'));
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => {
|
||||
stubUserAgent(WECHAT_IOS_UA);
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.wechat-guide[role="dialog"]').exists()).toBe(true);
|
||||
expect(wrapper.find('.wechat-guide h2').text()).toBe('保存图片指引');
|
||||
expect(mockedGetPhotoDownload).not.toHaveBeenCalled();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
// 知道了:浮层收起(再点击可重新触发)。
|
||||
await wrapper.find('.guide-dismiss').trigger('click');
|
||||
expect(wrapper.find('.wechat-guide').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('guidance copy link writes the current photo deep link with feedback', async () => {
|
||||
stubUserAgent(WECHAT_IOS_UA);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await wrapper.find('.guide-copy').trigger('click');
|
||||
await flushPromises();
|
||||
expect(writeText).toHaveBeenCalledWith(window.location.href);
|
||||
expect(wrapper.find('.copy-status').text()).toBe('已复制链接');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,9 @@ export function makeAlbum(
|
||||
],
|
||||
banner: null,
|
||||
viewCount: null,
|
||||
// P4a Task 4:下载两级开关(契约 PublicAlbum 必返字段;缺省 on/off)。
|
||||
downloadEnabled: true,
|
||||
originalDownloadEnabled: false,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export type LikeBatchResponse = components['schemas']['LikeBatchResponse'];
|
||||
export type VisitReportRequest = components['schemas']['VisitReportRequest'];
|
||||
export type VisitReportResponse = components['schemas']['VisitReportResponse'];
|
||||
|
||||
// P4a Task 4 下载端点类型(downloadPublicPhoto,ov 域;A18 两级开关)。
|
||||
export type PublicPhotoDownload = components['schemas']['PublicPhotoDownload'];
|
||||
export type DownloadVariant = components['schemas']['DownloadVariant'];
|
||||
|
||||
/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */
|
||||
export type SortMode = 'latest' | 'hot';
|
||||
|
||||
@@ -146,3 +150,16 @@ export function postAlbumVisit(token: string, body: VisitReportRequest): Promise
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** 公开照片下载凭证(P4a Task 4):短期签名 URL,过期经本端点刷新(重取一次)。 */
|
||||
export function getPhotoDownload(
|
||||
token: string,
|
||||
photoId: string,
|
||||
variant: DownloadVariant,
|
||||
): Promise<PublicPhotoDownload> {
|
||||
return apiRequest<PublicPhotoDownload>(
|
||||
withQuery(`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}/download`, {
|
||||
variant,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PhotoSequence } from '../feed/sequence';
|
||||
import type { DownloadController } from '../download/useDownload';
|
||||
import type { LikesController } from '../likes/useLikes';
|
||||
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
|
||||
|
||||
@@ -20,6 +21,11 @@ import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from
|
||||
// · 双击(鼠标 dblclick / 触控双 tap):复位缩放;
|
||||
// · 滚轮(无需 ctrl):以光标为不动点缩放;Esc/关闭按钮/背景点击关闭。
|
||||
//
|
||||
// 下载(P4a Task 4):工具栏入口按相册两级开关渲染(控制器 canDownload /
|
||||
// canDownloadOriginal;download prop 缺省不渲染——组件单测/降级组装)。原图
|
||||
// 可下时为分体按钮(主按钮=浏览图 + ▾ 菜单两选项);微信内置浏览器由控制器
|
||||
// 判定并改走引导浮层(长按保存 + 复制深链)。
|
||||
//
|
||||
// 关闭回位(滚动锚点)与 URL 同步由 ActivityPage 负责(见 ActivityPage 注释)。
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -27,6 +33,8 @@ const props = defineProps<{
|
||||
photoId: string;
|
||||
/** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */
|
||||
likes?: LikesController;
|
||||
/** 下载控制器(P4a Task 4):缺省不渲染下载位(组件单测/降级组装)。 */
|
||||
download?: DownloadController;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -263,6 +271,29 @@ function likeCurrent(): void {
|
||||
props.likes.like(photo.value.photoId);
|
||||
}
|
||||
|
||||
// —— 下载(P4a Task 4):菜单开合随照片切换/关闭复位 ——
|
||||
|
||||
const downloadMenuOpen = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.photoId,
|
||||
() => {
|
||||
downloadMenuOpen.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
/** 菜单选项点击:收起菜单后发起对应 variant 下载。 */
|
||||
function pickDownload(variant: 'browse' | 'original'): void {
|
||||
downloadMenuOpen.value = false;
|
||||
void props.download?.download(variant);
|
||||
}
|
||||
|
||||
/** 主按钮:无原图位时直下浏览图;有原图位时也直下浏览图(菜单提供两项)。 */
|
||||
function downloadBrowse(): void {
|
||||
downloadMenuOpen.value = false;
|
||||
void props.download?.download('browse');
|
||||
}
|
||||
|
||||
// —— 签名过期/下线处理 ——
|
||||
|
||||
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
|
||||
@@ -348,7 +379,40 @@ onBeforeUnmount(() => {
|
||||
<span class="like-count">{{ likes.displayFor(photo) }}</span>
|
||||
</button>
|
||||
<span v-else class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
|
||||
<!-- P4a 预留:下载 / 分享操作位(P1b 不实现)。 -->
|
||||
<!-- 下载(P4a Task 4):两级开关驱动——都关无入口;browse 单按钮;
|
||||
original 可下 → 分体按钮(主=浏览图 + ▾ 菜单两项)。 -->
|
||||
<div
|
||||
v-if="download !== undefined && download.canDownload"
|
||||
class="download-group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="control download"
|
||||
:disabled="download.busy"
|
||||
:aria-label="t('download.action')"
|
||||
@click="downloadBrowse"
|
||||
>
|
||||
{{ download.busy ? t('download.busy') : t('download.action') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="download.canDownloadOriginal"
|
||||
type="button"
|
||||
class="control download-caret"
|
||||
:aria-label="t('download.options')"
|
||||
:aria-expanded="downloadMenuOpen"
|
||||
@click="downloadMenuOpen = !downloadMenuOpen"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
<div v-if="downloadMenuOpen && download.canDownloadOriginal" class="download-menu" role="menu">
|
||||
<button type="button" role="menuitem" @click="pickDownload('browse')">
|
||||
{{ t('download.action') }}
|
||||
</button>
|
||||
<button type="button" role="menuitem" @click="pickDownload('original')">
|
||||
{{ t('download.original') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
|
||||
✕
|
||||
</button>
|
||||
@@ -374,6 +438,41 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
|
||||
|
||||
<!-- 下载失败轻提示(端点失败/网络;可重点击重试)。 -->
|
||||
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
|
||||
{{ t('download.failed') }}
|
||||
</p>
|
||||
|
||||
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
|
||||
<div
|
||||
v-if="download !== undefined && download.wechatGuide"
|
||||
class="wechat-guide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('download.wechatTitle')"
|
||||
>
|
||||
<div class="wechat-card">
|
||||
<h2>{{ t('download.wechatTitle') }}</h2>
|
||||
<p>{{ t('download.wechatHint') }}</p>
|
||||
<!-- clipboard 不可用降级:展示可选中文本供手动长按复制。 -->
|
||||
<p v-if="download.copyNotice === 'failed'" class="copy-fallback">{{ download.shareUrl() }}</p>
|
||||
<div class="guide-actions">
|
||||
<button type="button" class="guide-copy" @click="download.copyShareLink()">
|
||||
{{ t('download.copyLink') }}
|
||||
</button>
|
||||
<span v-if="download.copyNotice === 'copied'" class="copy-status" role="status">
|
||||
{{ t('download.copied') }}
|
||||
</span>
|
||||
<span v-else-if="download.copyNotice === 'failed'" class="copy-status" role="status">
|
||||
{{ t('download.copyFailed') }}
|
||||
</span>
|
||||
<button type="button" class="guide-dismiss" @click="download.dismissWechatGuide()">
|
||||
{{ t('download.dismiss') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -485,6 +584,125 @@ onBeforeUnmount(() => {
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
/* —— 下载(P4a Task 4)—— */
|
||||
.download-group {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.control.download {
|
||||
border: 1px solid rgba(249, 250, 251, 0.35);
|
||||
border-radius: 999px 0 0 999px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.control.download:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.control.download-caret {
|
||||
border: 1px solid rgba(249, 250, 251, 0.35);
|
||||
border-left: none;
|
||||
border-radius: 0 999px 999px 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.download-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
display: grid;
|
||||
min-width: 9em;
|
||||
border-radius: 8px;
|
||||
background: rgba(17, 24, 39, 0.96);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
.download-menu button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #f9fafb;
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.download-menu button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.download-failed {
|
||||
position: absolute;
|
||||
bottom: 52px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(185, 28, 28, 0.92);
|
||||
color: #fef2f2;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* —— 微信引导浮层 —— */
|
||||
.wechat-guide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(17, 24, 39, 0.72);
|
||||
padding: 24px;
|
||||
}
|
||||
.wechat-card {
|
||||
max-width: 420px;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.wechat-card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.wechat-card p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.copy-fallback {
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
background: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.guide-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.guide-actions button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 6px 16px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.guide-copy {
|
||||
background: #1d4ed8;
|
||||
color: #fff;
|
||||
}
|
||||
.guide-dismiss {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.copy-status {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.control.nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// apps/viewer/src/download/useDownload.ts —— 大图下载状态机(P4a Task 4,
|
||||
// T12 收口)。两级开关(A18)由相册载荷驱动:
|
||||
// · downloadEnabled=false → 工具栏无下载入口(后端同样 403,前端不渲染);
|
||||
// · browse-only → 单按钮「下载图片」;
|
||||
// · originalDownloadEnabled(且 downloadEnabled)→ 分体按钮 + 菜单两项
|
||||
// (下载图片 / 下载原图——original 是更高级开关,服务端级联校验)。
|
||||
//
|
||||
// 下载流:点击 → GET download 端点取短期签名凭证 → window.open 新标签直取
|
||||
// 对象存储(跨源 fetch+blob 会撞 MinIO presign 的 CORS 面,新标签是本地仿真
|
||||
// 与生产同走的稳健路径;`<a download>` 对跨源 URL 的文件名属性被浏览器忽略,
|
||||
// 不采用)。过期重取一次:响应 expiresAt 已过期(缓存/时钟漂移)→ 再请求
|
||||
// 本端点一次(契约指定的刷新入口),之后无论新旧都打开(服务端时钟偏移的
|
||||
// 极端情形交由用户重点击)。
|
||||
//
|
||||
// 微信内置浏览器(MicroMessenger UA):不支持程序化下载/新标签导航——以
|
||||
// 引导浮层替代直接下载:长按图片保存指引 + 复制当前照片深链(外部浏览器
|
||||
// 打开后可正常下载)。clipboard 不可用时降级展示可选中文本供手动长按复制。
|
||||
import { computed, onBeforeUnmount, reactive, ref, type Ref } from 'vue';
|
||||
import {
|
||||
getPhotoDownload,
|
||||
type DownloadVariant,
|
||||
type PublicAlbum,
|
||||
} from '../api/client';
|
||||
|
||||
export interface DownloadInputs {
|
||||
token: Ref<string>;
|
||||
/** 当前相册载荷(开关来源;未加载 = 视为关闭)。 */
|
||||
album: () => PublicAlbum | null;
|
||||
/** 当前大图照片(无当前照片时下载为 no-op)。 */
|
||||
photoId: () => string | null;
|
||||
}
|
||||
|
||||
/** 微信内置浏览器判定(iOS/Android/桌面微信 UA 均含 MicroMessenger)。 */
|
||||
export function isWechatWebview(userAgent: string): boolean {
|
||||
return /MicroMessenger/i.test(userAgent);
|
||||
}
|
||||
|
||||
/** 凭证是否已过期(时钟漂移/缓存响应的防线;≤ now 视为过期)。 */
|
||||
export function isExpired(expiresAt: string, now: number = Date.now()): boolean {
|
||||
const timestamp = Date.parse(expiresAt);
|
||||
return Number.isFinite(timestamp) && timestamp <= now;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开下载 URL:新标签直达对象存储。弹窗被拦(window.open 返回 null)→
|
||||
* 合成 `<a target=_blank>` 点击兜底(仍在用户手势帧内,多数浏览器放行);
|
||||
* 导航结果不可观测,best-effort。
|
||||
*/
|
||||
export function openDownloadUrl(url: string): void {
|
||||
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (opened !== null) return;
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
/** 复制反馈停留毫秒(之后自动消失)。 */
|
||||
export const COPY_NOTICE_MS = 2500;
|
||||
|
||||
export function useDownload(inputs: DownloadInputs) {
|
||||
const busy = ref(false);
|
||||
const failed = ref(false);
|
||||
/** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */
|
||||
const wechatGuide = ref(false);
|
||||
const copyNotice = ref<'' | 'copied' | 'failed'>('');
|
||||
|
||||
/** 入口可见性:browse 级开关(相册未加载 = 关闭,不渲染入口)。 */
|
||||
const canDownload = computed(() => inputs.album()?.downloadEnabled === true);
|
||||
/** 原图项可见性:两级级联(downloadEnabled && originalDownloadEnabled)。 */
|
||||
const canDownloadOriginal = computed(
|
||||
() =>
|
||||
inputs.album()?.downloadEnabled === true &&
|
||||
inputs.album()?.originalDownloadEnabled === true,
|
||||
);
|
||||
|
||||
/** 当前照片深链(引导浮层复制目标;大图打开期间即当前 URL)。 */
|
||||
function shareUrl(): string {
|
||||
return window.location.href;
|
||||
}
|
||||
|
||||
async function download(variant: DownloadVariant): Promise<void> {
|
||||
if (isWechatWebview(navigator.userAgent)) {
|
||||
wechatGuide.value = true;
|
||||
return;
|
||||
}
|
||||
const photoId = inputs.photoId();
|
||||
if (photoId === null || busy.value) return;
|
||||
busy.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
let credential = await getPhotoDownload(inputs.token.value, photoId, variant);
|
||||
if (isExpired(credential.expiresAt)) {
|
||||
// 过期凭证:重取一次(契约刷新入口即本端点),仍过期则照开(极端
|
||||
// 时钟偏移交用户重点击——不做无限重取)。
|
||||
credential = await getPhotoDownload(inputs.token.value, photoId, variant);
|
||||
}
|
||||
openDownloadUrl(credential.url);
|
||||
} catch {
|
||||
failed.value = true;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismissWechatGuide(): void {
|
||||
wechatGuide.value = false;
|
||||
}
|
||||
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function copyShareLink(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl());
|
||||
copyNotice.value = 'copied';
|
||||
} catch {
|
||||
copyNotice.value = 'failed';
|
||||
}
|
||||
if (copyTimer !== undefined) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => {
|
||||
copyNotice.value = '';
|
||||
}, COPY_NOTICE_MS);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyTimer !== undefined) clearTimeout(copyTimer);
|
||||
});
|
||||
|
||||
// reactive 包装解包 ref/computed:模板与调用方直接读布尔(同 useLikes 约定)。
|
||||
return reactive({
|
||||
busy,
|
||||
failed,
|
||||
wechatGuide,
|
||||
copyNotice,
|
||||
canDownload,
|
||||
canDownloadOriginal,
|
||||
shareUrl,
|
||||
download,
|
||||
dismissWechatGuide,
|
||||
copyShareLink,
|
||||
});
|
||||
}
|
||||
|
||||
export type DownloadController = ReturnType<typeof useDownload>;
|
||||
@@ -19,6 +19,20 @@ export default {
|
||||
},
|
||||
lang: { switch: 'Switch language' },
|
||||
prefs: { reduceMotion: 'Reduce motion' },
|
||||
download: {
|
||||
action: 'Download photo',
|
||||
original: 'Download original',
|
||||
options: 'Download options',
|
||||
busy: 'Preparing…',
|
||||
failed: 'Download failed, please try again',
|
||||
// WeChat in-app browser guidance (no programmatic download): long-press save + copy link.
|
||||
wechatTitle: 'How to save this photo',
|
||||
wechatHint: 'Pages opened inside WeChat cannot download directly. Long-press the photo above to save it, or copy the link and open it in a browser to download.',
|
||||
copyLink: 'Copy link',
|
||||
copied: 'Link copied',
|
||||
copyFailed: 'Copy failed — long-press to select the link below',
|
||||
dismiss: 'Got it',
|
||||
},
|
||||
viewer: {
|
||||
title: 'Photo viewer',
|
||||
close: 'Close',
|
||||
|
||||
@@ -19,6 +19,20 @@ export default {
|
||||
},
|
||||
lang: { switch: '切换语言' },
|
||||
prefs: { reduceMotion: '减少动画' },
|
||||
download: {
|
||||
action: '下载图片',
|
||||
original: '下载原图',
|
||||
options: '下载选项',
|
||||
busy: '准备中…',
|
||||
failed: '下载失败,请重试',
|
||||
// 微信内置浏览器引导(不支持程序化下载):长按保存 + 复制链接离场。
|
||||
wechatTitle: '保存图片指引',
|
||||
wechatHint: '微信内打开的页面不支持直接下载。请长按上方图片保存到手机,或复制链接后在浏览器中打开再下载。',
|
||||
copyLink: '复制链接',
|
||||
copied: '已复制链接',
|
||||
copyFailed: '复制失败,请长按选择下方链接复制',
|
||||
dismiss: '知道了',
|
||||
},
|
||||
viewer: {
|
||||
title: '照片查看',
|
||||
close: '关闭',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type SortMode,
|
||||
} from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import { useDownload } from '../download/useDownload';
|
||||
import LikeBubbles from '../components/LikeBubbles.vue';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
@@ -222,6 +223,13 @@ const currentPhoto = computed(() => {
|
||||
return sequence.photos.find((photo) => photo.photoId === lightboxPhotoId.value) ?? null;
|
||||
});
|
||||
|
||||
/** 下载控制器(P4a Task 4):开关来自相册载荷,当前照片随大图键控。 */
|
||||
const download = useDownload({
|
||||
token,
|
||||
album: () => album.value,
|
||||
photoId: () => lightboxPhotoId.value,
|
||||
});
|
||||
|
||||
function showRemovedNotice(): void {
|
||||
photoRemovedNotice.value = true;
|
||||
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
|
||||
@@ -444,6 +452,7 @@ onBeforeUnmount(() => {
|
||||
v-model:photo-id="lightboxPhotoId"
|
||||
:sequence="sequence"
|
||||
:likes="likes"
|
||||
:download="download"
|
||||
@removed="onPhotoRemoved"
|
||||
@close="closeLightbox"
|
||||
/>
|
||||
|
||||
@@ -3844,6 +3844,8 @@ components:
|
||||
- categories
|
||||
- banner
|
||||
- viewCount
|
||||
- downloadEnabled
|
||||
- originalDownloadEnabled
|
||||
properties:
|
||||
publicToken:
|
||||
type: string
|
||||
@@ -3889,6 +3891,18 @@ components:
|
||||
累计浏览数。cn 部署返回 null(P1a 占位,Task 9 起由统计副本回填);
|
||||
ov 部署返回 album_stats 权威累计值(无行默认 0;计数由 /visits
|
||||
端点维护,技术实施方案 §7.1)。
|
||||
downloadEnabled:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
description: |
|
||||
浏览图下载开关(P4a Task 4 / A18):false 时观众端不出现下载入口,
|
||||
download 端点 browse/original 均 403 DOWNLOAD_DISABLED。
|
||||
originalDownloadEnabled:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
description: |
|
||||
原图下载开关(更高级:需与 downloadEnabled 同时开启才允许
|
||||
variant=original;单独开启不生效——两级级联语义)。
|
||||
|
||||
PublicPhoto:
|
||||
type: object
|
||||
|
||||
@@ -33,5 +33,9 @@ export const LIKE_BURST_MERGE_MS = 500;
|
||||
export const VISIT_REQUEST_TTL_MINUTES = 35;
|
||||
/** 浏览上报基础限速:visitorId+IP 固定窗(=去重窗口)内请求数上限,超限 429。 */
|
||||
export const VISIT_WINDOW_REQUESTS = 60;
|
||||
/** 公开照片下载基础限速:来源 IP 固定窗(DOWNLOAD_WINDOW_SECONDS)内请求数上限。 */
|
||||
export const DOWNLOAD_WINDOW_REQUESTS = 120;
|
||||
/** 下载限速固定窗秒数(P4a Task 4;无幂等面可借窗,独立短窗)。 */
|
||||
export const DOWNLOAD_WINDOW_SECONDS = 60;
|
||||
/** ov 互动流水清理调度间隔分钟(like_batches/visit_requests/visit_sessions)。 */
|
||||
export const CLEANUP_INTERVAL_MINUTES = 60;
|
||||
|
||||
+10
@@ -2006,6 +2006,16 @@ export interface components {
|
||||
* 端点维护,技术实施方案 §7.1)。
|
||||
*/
|
||||
readonly viewCount: number | null;
|
||||
/**
|
||||
* @description 浏览图下载开关(P4a Task 4 / A18):false 时观众端不出现下载入口,
|
||||
* download 端点 browse/original 均 403 DOWNLOAD_DISABLED。
|
||||
*/
|
||||
readonly downloadEnabled: boolean;
|
||||
/**
|
||||
* @description 原图下载开关(更高级:需与 downloadEnabled 同时开启才允许
|
||||
* variant=original;单独开启不生效——两级级联语义)。
|
||||
*/
|
||||
readonly originalDownloadEnabled: boolean;
|
||||
};
|
||||
/** @description 公开照片条目(仅 published 照片,处理完成即有宽高;URL 为短期签名,需在过期前刷新)。 */
|
||||
PublicPhoto: {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user