diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 2276208..2cba2df 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -8,7 +8,7 @@ import uuid from datetime import datetime from typing import List from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import FileResponse, RedirectResponse +from fastapi.responses import FileResponse from starlette.background import BackgroundTask from sqlalchemy.orm import Session @@ -344,7 +344,19 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get db.commit() if not local_exists: - return RedirectResponse(task.dsym_url) + # dSYM 需要携带认证信息才能访问该接口,浏览器无法直接跳转到本接口的 + # 302 目标,因此改为返回 JSON 让前端自行发起下载。 + try: + from .config import load_config + from ..services.distribution import DistributionError, get_published_dsym_url + download_url = get_published_dsym_url( + json.loads(task.config_json or "{}"), + load_config().get("upload", {}), + task.dsym_url, + ) + except (json.JSONDecodeError, DistributionError) as exc: + raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc + return {"url": download_url} # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载 if os.path.isdir(dsym_path): diff --git a/backend/services/distribution.py b/backend/services/distribution.py index 886cd72..827162f 100644 --- a/backend/services/distribution.py +++ b/backend/services/distribution.py @@ -315,14 +315,7 @@ def delete_published_dsym(build_config: dict, upload_config: dict): deleter(upload_config, [_dsym_remote_path(build_config)]) -def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str: - """返回用于受控下载的短时链接;WebDAV 保持原公开链接。""" - if upload_config.get("mode") != "oss": - return published_url - - remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url) - if not remote_paths: - raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接") +def _sign_oss_url(upload_config: dict, remote_path: str) -> str: oss = upload_config.get("oss", {}) required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"] if any(not oss.get(key) for key in required): @@ -336,7 +329,29 @@ def get_published_download_url(build_config: dict, upload_config: dict, publishe oss2.Auth(oss["access_key_id"], oss["access_key_secret"]), oss["endpoint"], oss["bucket_name"], connect_timeout=30, ) - return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True) + return bucket.sign_url("GET", remote_path, DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True) + + +def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str: + """返回用于受控下载的短时链接;WebDAV 保持原公开链接。""" + if upload_config.get("mode") != "oss": + return published_url + + remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url) + if not remote_paths: + raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接") + return _sign_oss_url(upload_config, remote_paths[0]) + + +def get_published_dsym_url(build_config: dict, upload_config: dict, dsym_url: str) -> str: + """返回 dSYM 受控下载的短时链接;WebDAV 保持原公开链接。 + + dSYM 的远端对象键完全由构建配置决定,直接按命名规则重新计算即可, + 不需要像 IPA 那样从已保存的下载链接反推。 + """ + if upload_config.get("mode") != "oss": + return dsym_url + return _sign_oss_url(upload_config, _dsym_remote_path(build_config)) def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str): diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue index 26806d9..adae8f6 100644 --- a/frontend/src/views/HistoryView.vue +++ b/frontend/src/views/HistoryView.vue @@ -309,8 +309,36 @@ onUnmounted(() => { if (logWs) { logWs.close(); logWs = null } }) -const downloadDsym = (taskId) => { - window.open(`/api/tasks/${taskId}/dsym`) +const triggerBlobDownload = (blob, filename) => { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename || '' + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +const filenameFromDisposition = (res, fallback) => { + const cd = res.headers.get('content-disposition') || '' + const match = cd.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i) + return match ? decodeURIComponent(match[1]) : fallback +} + +const downloadDsym = async (taskId) => { + try { + const res = await authFetch(`/api/tasks/${taskId}/dsym`) + if (!res.ok) throw new Error() + if ((res.headers.get('content-type') || '').includes('application/json')) { + const { url } = await res.json() + window.open(url, '_blank') + } else { + triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'dsym.zip')) + } + } catch { + alert('下载失败,请稍后重试') + } } const downloadIpa = async (taskId) => { @@ -328,8 +356,14 @@ const downloadIpa = async (taskId) => { } } -const downloadObfMaps = (taskId) => { - window.open(`/api/tasks/${taskId}/obfuscation-maps`) +const downloadObfMaps = async (taskId) => { + try { + const res = await authFetch(`/api/tasks/${taskId}/obfuscation-maps`) + if (!res.ok) throw new Error() + triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'obfuscation_maps.zip')) + } catch { + alert('下载失败,请稍后重试') + } } const deleteTask = async (taskId) => { diff --git a/tests/test_downloads.py b/tests/test_downloads.py index 4d7b153..6fac41e 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -67,15 +67,17 @@ def test_download_dsym_nonexistent_task(client, tmp_config): def test_download_dsym_remote_fallback(client, tmp_config): - """本地文件已被清理,但保存了远端地址时应重定向到远端""" + """本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向, + 因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)""" task_id = _create_task_with_files( client, tmp_config, dsym_path="/tmp/nonexistent.dSYM", dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip", ) - resp = client.get(f"/api/tasks/{task_id}/dsym", follow_redirects=False) - assert resp.status_code in (302, 307) - assert resp.headers["location"] == "https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip" + with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"): + resp = client.get(f"/api/tasks/{task_id}/dsym") + assert resp.status_code == 200 + assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1" # ---- 混淆映射表下载 ----