feat: dSYM 按上传配置一并保存到远端,本地清理后回退到远端地址下载
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c17b7d5ad9
commit
1466e24c83
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user