iOSBuildServer/backend/routers/tasks.py
shen 1cecf66d94 feat: 全站登录保护,管理后台仅管理员可访问
- tasks 路由级认证保护,所有 API 需登录
- 未登录时显示登录页面,不暴露导航和功能
- ConfigView 仅管理员可访问,普通用户显示权限提示
- BuildView/HistoryView 所有 API 请求携带 token
2026-06-08 20:59:13 +08:00

276 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""任务 API"""
import os
import shutil
import tempfile
import uuid
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Task
from ..schemas import TaskCreate, TaskResponse
from ..deps import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
@router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
"""创建打包任务"""
from .config import load_config
config = load_config()
apps = config.get("apps", {})
schemes = config.get("schemes", {})
if task.app_id not in apps:
raise HTTPException(status_code=400, detail="App 不存在")
if task.scheme_id not in schemes:
raise HTTPException(status_code=400, detail="Scheme 不存在")
app = apps[task.app_id]
scheme = schemes[task.scheme_id]
task_id = str(uuid.uuid4())
db_task = Task(
id=task_id,
app_id=task.app_id,
app_name=app.get("name", ""),
build_type=task.build_type,
scheme_id=task.scheme_id,
scheme_name=scheme.get("displayName") or scheme.get("name", ""),
obfuscation=task.obfuscation,
branch=task.branch,
status="pending",
)
db.add(db_task)
db.commit()
db.refresh(db_task)
# 启动打包任务
from ..services.build_queue import build_queue
await build_queue.submit(task_id)
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,
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db)
):
"""获取任务列表"""
query = db.query(Task)
if status:
query = query.filter(Task.status == status)
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
return _enrich_tasks(tasks)
@router.get("/{task_id}", response_model=TaskResponse)
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 _enrich_tasks([task])[0]
@router.delete("/{task_id}")
async def cancel_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="任务不存在")
if task.status in ("completed", "failed", "cancelled"):
raise HTTPException(status_code=400, detail="任务已完成或已取消")
task.status = "cancelled"
db.commit()
# 尝试取消运行中的任务
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)):
"""获取打包日志"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
from ..services.log_streamer import log_streamer
# 先从内存中获取(任务运行中)
lines = log_streamer._log_lines.get(task_id)
if lines:
content = ""
for entry in lines:
ts = entry["timestamp"]
level = entry["level"].upper()
msg = entry["message"]
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:
log_path = Path(task.build_dir) / "build.log"
if log_path.exists():
return {"log": log_path.read_text(encoding="utf-8")}
return {"log": ""}
@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 文件不存在")
dsym_path = task.dsym_path
if not os.path.exists(dsym_path):
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
# .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")
async def download_obfuscation_maps(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="任务不存在")
if not task.obfuscation_maps_path:
raise HTTPException(status_code=404, detail="混淆映射表不存在")
maps_path = task.obfuscation_maps_path
if not os.path.exists(maps_path):
raise HTTPException(status_code=404, detail="混淆映射表已被清理")
# 如果是目录,打包成 zip
if os.path.isdir(maps_path):
fd, zip_path = tempfile.mkstemp(suffix=".zip")
os.close(fd)
shutil.make_archive(zip_path.replace(".zip", ""), "zip", maps_path)
return FileResponse(
zip_path,
filename=f"{task.app_name}_混淆映射.zip",
background=BackgroundTask(os.remove, zip_path),
)
return FileResponse(maps_path, filename=os.path.basename(maps_path))
@router.get("/{task_id}/qrcode")
async def download_qrcode(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="任务不存在")
if not task.qr_code_path:
raise HTTPException(status_code=404, detail="二维码不存在")
if not os.path.exists(task.qr_code_path):
raise HTTPException(status_code=404, detail="二维码文件已被清理")
return FileResponse(task.qr_code_path, media_type="image/png")