From 1466e24c83757709b9ca136d781ff5db85e13d7a Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 26 Aug 2026 16:02:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20dSYM=20=E6=8C=89=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=80=E5=B9=B6=E4=BF=9D=E5=AD=98=E5=88=B0?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=EF=BC=8C=E6=9C=AC=E5=9C=B0=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=9B=9E=E9=80=80=E5=88=B0=E8=BF=9C=E7=AB=AF=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- backend/database.py | 1 + backend/models.py | 1 + backend/routers/tasks.py | 43 ++++++++++++++++++++++++------- backend/schemas.py | 1 + backend/services/build_service.py | 38 ++++++++++++++++++++++----- backend/services/distribution.py | 39 ++++++++++++++++++++++++++++ tests/test_downloads.py | 12 +++++++++ 7 files changed, 119 insertions(+), 16 deletions(-) diff --git a/backend/database.py b/backend/database.py index d57646e..9280f0f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -25,6 +25,7 @@ def _migrate_db(): ("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "error_category", "VARCHAR"), ("tasks", "superseded_at", "TIMESTAMP"), + ("tasks", "dsym_url", "VARCHAR"), ] with engine.connect() as conn: for table, column, col_type in migrations: diff --git a/backend/models.py b/backend/models.py index 5d1f134..d8edabf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -32,6 +32,7 @@ class Task(Base): ipa_path = Column(String) oss_url = Column(String) dsym_path = Column(String) + dsym_url = Column(String) obfuscation_maps_path = Column(String) qr_code_path = Column(String) build_dir = Column(String) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 2982b6f..2276208 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 +from fastapi.responses import FileResponse, RedirectResponse from starlette.background import BackgroundTask from sqlalchemy.orm import Session @@ -205,6 +205,25 @@ async def delete_task(task_id: str, request: Request, user: dict = Depends(get_c except (json.JSONDecodeError, DistributionError) as exc: raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc + # dSYM 压缩包的远端对象键与 IPA 独立,同一版本/分支重复打包会复用并覆盖同一个远端文件, + # 仍有其他历史记录引用时不能删除。 + has_shared_dsym = bool( + task.dsym_url and db.query(Task).filter( + Task.id != task.id, + Task.dsym_url == task.dsym_url, + ).first() + ) + if task.dsym_url and not has_shared_dsym: + if not task.config_json: + raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端 dSYM 文件") + try: + build_config = json.loads(task.config_json) + from .config import load_config + from ..services.distribution import DistributionError, delete_published_dsym + delete_published_dsym(build_config, load_config().get("upload", {})) + except (json.JSONDecodeError, DistributionError) as exc: + raise HTTPException(status_code=502, detail=f"远端 dSYM 删除失败,记录未删除:{exc}") from exc + # 删除日志文件 log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" if log_path.exists(): @@ -302,7 +321,7 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends @router.get("/{task_id}/dsym") async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)): - """下载 dSYM 文件""" + """下载 dSYM 文件:本地文件已被清理时回退到构建时上传保存的远端地址""" import shutil import zipfile import tempfile @@ -310,17 +329,23 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") - if not task.dsym_path: - raise HTTPException(status_code=404, detail="dSYM 文件不存在") - from ..services.audit_log import record_operation - record_operation(db, user, "dsym_downloaded", request=request, task=task, - resource_type="dsym", resource_name=os.path.basename(task.dsym_path)) - db.commit() dsym_path = task.dsym_path - if not os.path.exists(dsym_path): + local_exists = bool(dsym_path) and os.path.exists(dsym_path) + if not local_exists and not task.dsym_url: + if not dsym_path: + raise HTTPException(status_code=404, detail="dSYM 文件不存在") raise HTTPException(status_code=404, detail="dSYM 文件已被清理") + from ..services.audit_log import record_operation + resource_name = os.path.basename(dsym_path) if dsym_path else os.path.basename(task.dsym_url) + record_operation(db, user, "dsym_downloaded", request=request, task=task, + resource_type="dsym", resource_name=resource_name) + db.commit() + + if not local_exists: + return RedirectResponse(task.dsym_url) + # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载 if os.path.isdir(dsym_path): zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip") diff --git a/backend/schemas.py b/backend/schemas.py index 6e9bba0..640c341 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -31,6 +31,7 @@ class TaskResponse(BaseModel): ipa_path: Optional[str] oss_url: Optional[str] dsym_path: Optional[str] + dsym_url: Optional[str] = None obfuscation_maps_path: Optional[str] qr_code_path: Optional[str] build_dir: Optional[str] diff --git a/backend/services/build_service.py b/backend/services/build_service.py index f0c12e4..b64acc2 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -23,7 +23,7 @@ from ..config import ( mask_git_remote_url, ) from .log_streamer import log_streamer -from .distribution import DistributionError, publish_ipa +from .distribution import DistributionError, publish_dsym, publish_ipa from .notification import NotificationError, send_dingtalk_notification from .project_patcher import ProjectPatchError, apply_project_config @@ -455,7 +455,7 @@ async def run_build_task(task_id: str): ipa_path = await build_project(task_id, task, config_data, build_dir) # 查找 dSYM - dsym_path = await find_dsym(task_id, build_dir) + dsym_path = await find_dsym(task_id, build_dir, config_data.get("SCHEME", "")) # 查找混淆映射表 obf_maps_path = build_dir / "obfuscation_maps" @@ -477,6 +477,13 @@ async def run_build_task(task_id: str): upload_fields["oss_url"] = oss_url if qr_code_path: upload_fields["qr_code_path"] = qr_code_path + + # dSYM 用于后续崩溃符号化,按上传配置一并保存到远端;失败不影响本次打包结果。 + if dsym_path: + dsym_url = await upload_dsym(task_id, task, config_data, dsym_path, build_dir) + if dsym_url: + upload_fields["dsym_url"] = dsym_url + if upload_fields: await asyncio.to_thread(_db_update, db, task, **upload_fields) @@ -914,15 +921,22 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path) return ipa_path -async def find_dsym(task_id: str, build_dir: Path) -> Path: - """查找 dSYM 文件""" +async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path: + """查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM""" derived_data = build_dir / "build" / "derived_data" if not derived_data.exists(): return None - for dsym in derived_data.rglob("*.dSYM"): - return dsym - return None + dsyms = list(derived_data.rglob("*.dSYM")) + if not dsyms: + return None + + if scheme: + for dsym in dsyms: + if dsym.name == f"{scheme}.app.dSYM": + return dsym + + return dsyms[0] async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple: @@ -938,3 +952,13 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir) except DistributionError as exc: raise BuildError(str(exc), category="upload") from exc + + +async def upload_dsym(task_id: str, task, config_data: dict, dsym_path: Path, build_dir: Path) -> str: + """上传 dSYM,返回下载 URL;上传失败仅记录警告,不影响已完成的打包结果""" + await log_streamer.emit_step(task_id, "上传 dSYM") + try: + return await asyncio.to_thread(publish_dsym, config_data, dsym_path, build_dir) + except DistributionError as exc: + await log_streamer.emit(task_id, f"dSYM 上传失败: {exc}", level="warn") + return "" diff --git a/backend/services/distribution.py b/backend/services/distribution.py index fe90cae..886cd72 100644 --- a/backend/services/distribution.py +++ b/backend/services/distribution.py @@ -192,6 +192,12 @@ def _write_download_page(config: dict, html: Path, manifest_url: str): ) +def _dsym_remote_path(config: dict) -> str: + stem = _artifact_stem(config) + folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds" + return posixpath.join(folder, "iOS", f"{stem}.dSYM.zip") + + def _remote_paths(config: dict) -> tuple[str, str, str, str]: stem = _artifact_stem(config) folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds" @@ -295,6 +301,20 @@ def delete_published_artifacts( deleter(upload_config, remote_paths) +def delete_published_dsym(build_config: dict, upload_config: dict): + """删除任务对应的远端 dSYM 压缩包。 + + dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定, + 无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。 + """ + mode = upload_config.get("mode", "") + if mode not in {"oss", "webdav"}: + raise DistributionError("请选择 OSS 或 WebDAV 上传方式") + + deleter = _delete_oss if mode == "oss" else _delete_webdav + 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": @@ -409,3 +429,22 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str qrcode.make(urls[".html"]).save(qr_path) urls.update(uploader(upload, [(qr_path, qr_remote)])) return urls[".html"], urls[".png"] + + +def publish_dsym(config: dict, dsym_path: Path, build_dir: Path) -> str: + """压缩并上传主 target 的 dSYM,返回下载 URL。""" + upload = config.get("_upload_config", {}) + mode = upload.get("mode", "") + if mode not in {"oss", "webdav"}: + raise DistributionError("请选择 OSS 或 WebDAV 上传方式") + + output_dir = build_dir / "distribution" + output_dir.mkdir(parents=True, exist_ok=True) + zip_base = output_dir / f"{_artifact_stem(config)}.dSYM" + zip_path = Path(shutil.make_archive( + str(zip_base), "zip", root_dir=str(dsym_path.parent), base_dir=dsym_path.name, + )) + + uploader = _upload_oss if mode == "oss" else _upload_webdav + urls = uploader(upload, [(zip_path, _dsym_remote_path(config))]) + return urls[".zip"] diff --git a/tests/test_downloads.py b/tests/test_downloads.py index ff9a893..4d7b153 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -66,6 +66,18 @@ def test_download_dsym_nonexistent_task(client, tmp_config): assert resp.status_code == 404 +def test_download_dsym_remote_fallback(client, tmp_config): + """本地文件已被清理,但保存了远端地址时应重定向到远端""" + 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" + + # ---- 混淆映射表下载 ---- def test_download_obfuscation_maps_not_exist(client, tmp_config):