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:
@@ -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>;
|
||||
Reference in New Issue
Block a user