diff --git a/.env.example b/.env.example index a7ef3e7..795bbf2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/viewer/e2e/download.spec.ts b/apps/viewer/e2e/download.spec.ts new file mode 100644 index 0000000..c405387 --- /dev/null +++ b/apps/viewer/e2e/download.spec.ts @@ -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 { + 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 { + 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'); +}); diff --git a/apps/viewer/e2e/helpers.ts b/apps/viewer/e2e/helpers.ts index ca63fc5..3a69a52 100644 --- a/apps/viewer/e2e/helpers.ts +++ b/apps/viewer/e2e/helpers.ts @@ -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 }, }); diff --git a/apps/viewer/src/__tests__/download.spec.ts b/apps/viewer/src/__tests__/download.spec.ts new file mode 100644 index 0000000..e523bb0 --- /dev/null +++ b/apps/viewer/src/__tests__/download.spec.ts @@ -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(); + 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; + +/** 宿主实例形态: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(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: ``, + }); + 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('已复制链接'); + }); +}); diff --git a/apps/viewer/src/__tests__/fixtures.ts b/apps/viewer/src/__tests__/fixtures.ts index d6a29d7..b201a6e 100644 --- a/apps/viewer/src/__tests__/fixtures.ts +++ b/apps/viewer/src/__tests__/fixtures.ts @@ -27,6 +27,9 @@ export function makeAlbum( ], banner: null, viewCount: null, + // P4a Task 4:下载两级开关(契约 PublicAlbum 必返字段;缺省 on/off)。 + downloadEnabled: true, + originalDownloadEnabled: false, ...rest, }; } diff --git a/apps/viewer/src/api/client.ts b/apps/viewer/src/api/client.ts index 9907b11..8a116ee 100644 --- a/apps/viewer/src/api/client.ts +++ b/apps/viewer/src/api/client.ts @@ -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 { + return apiRequest( + withQuery(`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}/download`, { + variant, + }), + ); +} diff --git a/apps/viewer/src/components/PhotoLightbox.vue b/apps/viewer/src/components/PhotoLightbox.vue index d5e1df4..e8abcce 100644 --- a/apps/viewer/src/components/PhotoLightbox.vue +++ b/apps/viewer/src/components/PhotoLightbox.vue @@ -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(() => { - + +
+ + + +
@@ -374,6 +438,41 @@ onBeforeUnmount(() => {

{{ t('album.loading') }}

+ + + + + + @@ -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%; diff --git a/apps/viewer/src/download/useDownload.ts b/apps/viewer/src/download/useDownload.ts new file mode 100644 index 0000000..7f2d530 --- /dev/null +++ b/apps/viewer/src/download/useDownload.ts @@ -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 面,新标签是本地仿真 +// 与生产同走的稳健路径;`` 对跨源 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; + /** 当前相册载荷(开关来源;未加载 = 视为关闭)。 */ + 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)→ + * 合成 `` 点击兜底(仍在用户手势帧内,多数浏览器放行); + * 导航结果不可观测,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 { + 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 | undefined; + + async function copyShareLink(): Promise { + 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; diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 5bcee86..8ad7bcc 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -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', diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 3d9d331..d5dd697 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -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: '关闭', diff --git a/apps/viewer/src/pages/ActivityPage.vue b/apps/viewer/src/pages/ActivityPage.vue index e2e3cbb..f686a83 100644 --- a/apps/viewer/src/pages/ActivityPage.vue +++ b/apps/viewer/src/pages/ActivityPage.vue @@ -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" /> diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 0d4e62f..a8cac72 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -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 diff --git a/packages/contracts/src/constants.ts b/packages/contracts/src/constants.ts index 6a7eaac..e7615d8 100644 --- a/packages/contracts/src/constants.ts +++ b/packages/contracts/src/constants.ts @@ -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; diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 97e7518..08b4bed 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -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: { diff --git a/services/api/src/app-ov.module.ts b/services/api/src/app-ov.module.ts index c638ad1..0760528 100644 --- a/services/api/src/app-ov.module.ts +++ b/services/api/src/app-ov.module.ts @@ -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, diff --git a/services/api/src/interactions/downloads.controller.ts b/services/api/src/interactions/downloads.controller.ts new file mode 100644 index 0000000..dbab909 --- /dev/null +++ b/services/api/src/interactions/downloads.controller.ts @@ -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 { + const clientIp = ip === undefined ? undefined : ip.replace(/^::ffff:/, ''); + return this.downloads.download( + token, + photoId, + parseDownloadVariant(variant), + clientIp, + ); + } +} diff --git a/services/api/src/interactions/downloads.service.ts b/services/api/src/interactions/downloads.service.ts new file mode 100644 index 0000000..52bc5a6 --- /dev/null +++ b/services/api/src/interactions/downloads.service.ts @@ -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 { + // ① 相册/照片公开校验(同 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 { + 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(); + } + } +} diff --git a/services/api/src/interactions/interactions.module.ts b/services/api/src/interactions/interactions.module.ts index 140039b..577a9fd 100644 --- a/services/api/src/interactions/interactions.module.ts +++ b/services/api/src/interactions/interactions.module.ts @@ -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 {} diff --git a/services/api/src/public-ov/public-ov.module.ts b/services/api/src/public-ov/public-ov.module.ts index 81f4d26..e05afe8 100644 --- a/services/api/src/public-ov/public-ov.module.ts +++ b/services/api/src/public-ov/public-ov.module.ts @@ -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 {} diff --git a/services/api/src/public-ov/public-ov.service.ts b/services/api/src/public-ov/public-ov.service.ts index fac0802..210583c 100644 --- a/services/api/src/public-ov/public-ov.service.ts +++ b/services/api/src/public-ov/public-ov.service.ts @@ -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) { diff --git a/services/api/src/public-ov/storage-ov.module.ts b/services/api/src/public-ov/storage-ov.module.ts new file mode 100644 index 0000000..3b8999c --- /dev/null +++ b/services/api/src/public-ov/storage-ov.module.ts @@ -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 {} diff --git a/services/api/src/public/public.service.ts b/services/api/src/public/public.service.ts index a41f7db..d49e536 100644 --- a/services/api/src/public/public.service.ts +++ b/services/api/src/public/public.service.ts @@ -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, }; } diff --git a/services/api/src/shared/ov-browse-key.ts b/services/api/src/shared/ov-browse-key.ts new file mode 100644 index 0000000..fe456e0 --- /dev/null +++ b/services/api/src/shared/ov-browse-key.ts @@ -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; +} diff --git a/services/api/test/downloads.e2e-spec.ts b/services/api/test/downloads.e2e-spec.ts new file mode 100644 index 0000000..1d590a9 --- /dev/null +++ b/services/api/test/downloads.e2e-spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + }; + 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 { + return request(ovBase).get( + `/v1/public/albums/${album.token}/photos/${photoId}/download?variant=${variant}`, + ); + } + + /** cn browse 变体对象字节(browse 下载对照基准)。 */ + async function browseObjectOf(photoId: string): Promise { + 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 { + 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(AppCnModule, { + rawBody: true, + }); + configureApp(cnApp); + await cnApp.listen(0); + cnBase = await cnApp.getUrl(); + + ovApp = await NestFactory.create(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); + }); +}); diff --git a/services/api/test/public.e2e-spec.ts b/services/api/test/public.e2e-spec.ts index 0deab27..8c5c70b 100644 --- a/services/api/test/public.e2e-spec.ts +++ b/services/api/test/public.e2e-spec.ts @@ -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',