feat: 构建超时控制、日志持久化、任务删除、pod install 及多项 UI 优化
- 新增 BUILD_TIMEOUT_HOURS 配置,超时自动标记失败 - 日志保存到独立 backend/logs/ 目录,不随构建目录删除 - 新增任务删除 API 及前端删除按钮 - 构建流程增加 pod install 步骤 - 通过文件传递配置避免命令行参数截断 - Provisioning Profile 支持按名称搜索 - dSYM 下载支持目录打包为 zip - 前端端口可配置、历史页增加下载列和耗时显示 - 日志弹窗支持展开/收起详细日志 - 自动生成 upload_key 标识
This commit is contained in:
@@ -64,6 +64,17 @@ DEFAULT_SERVERS = {
|
||||
}
|
||||
|
||||
|
||||
def _ensure_upload_keys(config: dict) -> bool:
|
||||
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
|
||||
import uuid
|
||||
changed = False
|
||||
for app_id, app in config.get("apps", {}).items():
|
||||
if not app.get("upload_key"):
|
||||
app["upload_key"] = uuid.uuid4().hex[:8]
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""读取 config.json"""
|
||||
if not CONFIG_JSON_PATH.exists():
|
||||
@@ -77,6 +88,9 @@ def load_config() -> dict:
|
||||
config["branches"] = ["main"]
|
||||
if "upload" not in config:
|
||||
config["upload"] = DEFAULT_UPLOAD
|
||||
# 自动为缺少 upload_key 的 app 生成唯一标识
|
||||
if _ensure_upload_keys(config):
|
||||
save_config(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -119,6 +133,7 @@ async def get_apps():
|
||||
@router.post("/apps")
|
||||
async def create_app(app: dict):
|
||||
"""新增 app"""
|
||||
import uuid
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
@@ -127,6 +142,10 @@ async def create_app(app: dict):
|
||||
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
|
||||
# 自动生成 upload_key
|
||||
if not app.get("upload_key"):
|
||||
app["upload_key"] = uuid.uuid4().hex[:8]
|
||||
|
||||
apps[new_id] = app
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
|
||||
@@ -57,6 +57,30 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
||||
return db_task
|
||||
|
||||
|
||||
def _enrich_tasks(tasks):
|
||||
"""为任务列表补充 has_log 字段"""
|
||||
from pathlib import Path
|
||||
logs_dir = Path(__file__).parent.parent / "logs"
|
||||
result = []
|
||||
for task in tasks:
|
||||
resp = TaskResponse.from_orm(task)
|
||||
# 检查独立日志目录
|
||||
if logs_dir.exists() and (logs_dir / f"{task.id}.log").exists():
|
||||
resp.has_log = True
|
||||
# 检查构建目录
|
||||
elif task.build_dir:
|
||||
log_path = Path(task.build_dir) / "build.log"
|
||||
if log_path.exists():
|
||||
resp.has_log = True
|
||||
# 运行中的任务,内存中有日志
|
||||
elif task.status == "running":
|
||||
from ..services.log_streamer import log_streamer
|
||||
if task.id in log_streamer._log_lines:
|
||||
resp.has_log = True
|
||||
result.append(resp)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("", response_model=List[TaskResponse])
|
||||
async def list_tasks(
|
||||
status: str = None,
|
||||
@@ -69,7 +93,7 @@ async def list_tasks(
|
||||
if status:
|
||||
query = query.filter(Task.status == status)
|
||||
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return tasks
|
||||
return _enrich_tasks(tasks)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
@@ -78,7 +102,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return task
|
||||
return _enrich_tasks([task])[0]
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
@@ -98,9 +122,50 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
|
||||
from ..services.build_queue import build_queue
|
||||
build_queue.cancel(task_id)
|
||||
|
||||
# 清理残留文件
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
if task.build_dir:
|
||||
build_path = Path(task.build_dir)
|
||||
if build_path.exists():
|
||||
shutil.rmtree(build_path, ignore_errors=True)
|
||||
|
||||
return {"message": "任务已取消"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}/delete")
|
||||
async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
||||
"""删除打包记录"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status in ("running", "pending"):
|
||||
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
|
||||
|
||||
# 删除日志文件
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
|
||||
# 删除构建目录
|
||||
if task.build_dir:
|
||||
build_path = Path(task.build_dir)
|
||||
if build_path.exists():
|
||||
shutil.rmtree(build_path, ignore_errors=True)
|
||||
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
|
||||
return {"message": "记录已删除"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/log")
|
||||
async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
||||
"""获取打包日志"""
|
||||
@@ -121,9 +186,14 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
||||
content += f"[{ts}] [{level}] {msg}\n"
|
||||
return {"log": content}
|
||||
|
||||
# 从文件获取(任务已完成)
|
||||
# 从独立日志目录获取
|
||||
from pathlib import Path
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
return {"log": log_path.read_text(encoding="utf-8")}
|
||||
|
||||
# 从构建目录获取(兼容旧数据)
|
||||
if task.build_dir:
|
||||
from pathlib import Path
|
||||
log_path = Path(task.build_dir) / "build.log"
|
||||
if log_path.exists():
|
||||
return {"log": log_path.read_text(encoding="utf-8")}
|
||||
@@ -134,16 +204,32 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
||||
@router.get("/{task_id}/dsym")
|
||||
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
||||
"""下载 dSYM 文件"""
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
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 文件不存在")
|
||||
|
||||
if not os.path.exists(task.dsym_path):
|
||||
dsym_path = task.dsym_path
|
||||
if not os.path.exists(dsym_path):
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
|
||||
|
||||
return FileResponse(task.dsym_path, filename=os.path.basename(task.dsym_path))
|
||||
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
|
||||
if os.path.isdir(dsym_path):
|
||||
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(dsym_path):
|
||||
for f in files:
|
||||
full = os.path.join(root, f)
|
||||
arcname = os.path.relpath(full, os.path.dirname(dsym_path))
|
||||
zf.write(full, arcname)
|
||||
return FileResponse(zip_path, filename=os.path.basename(zip_path), media_type="application/zip")
|
||||
|
||||
return FileResponse(dsym_path, filename=os.path.basename(dsym_path))
|
||||
|
||||
|
||||
@router.get("/{task_id}/obfuscation-maps")
|
||||
|
||||
Reference in New Issue
Block a user