feat(ov,viewer): two-tier download with wechat guidance

P4a Task 4(T12 收口):ov 域公开照片下载端点 + viewer 下载 UI。

- interactions 增 downloads:GET /public/albums/{token}/photos/{photoId}
  /download?variant=browse|original。两级开关(A18):browse 需
  downloadEnabled;original 需叠加 originalDownloadEnabled(级联,browse 关
  则两者均 403 DOWNLOAD_DISABLED)。水印相册 browse 现签 watermark pattern
  key(browse-key 解析抽取 shared/ov-browse-key 与 public-ov 复用);原图
  现签 originalObjectKey(SIGNED_URL_TTL)。来源 IP 固定窗限速
  (DOWNLOAD_WINDOW_REQUESTS/DOWNLOAD_WINDOW_SECONDS,env 可调);零浏览
  计数写入(排除规则)。STORAGE_PORT_OV 经新 StorageOvModule 与 public-ov
  共享同一实例。
- 契约微调登记:PublicAlbum 增 readOnly 必返 downloadEnabled/
  originalDownloadEnabled(cn+ov 同形返回;public e2e 精确键断言同步)。
- viewer:lightbox 下载入口随开关渲染(无/单按钮/分体菜单两选项)、过期
  凭证静默重取一次、微信内置浏览器(MicroMessenger UA)出保存图片指引
  浮层(复制深链 + 反馈)替代直接下载;i18n zh/en 词条。
- tests:api e2e +7(开关矩阵 + URL/字节级断言、水印键一致、隐藏 404、
  限速 429、零计数行);viewer vitest +9(UA/矩阵/菜单/重取/失败/指引/
  复制);viewer Playwright +3(入口矩阵、微信 UA 浮层、真实端点 200 +
  签名 URL 模式)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 18:32:34 +08:00
parent 243b2f8721
commit 2bb6f291d3
25 changed files with 1622 additions and 41 deletions
+147
View File
@@ -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>;