历史记录点击 dSYM 下载后停留在接口地址本身:window.open 无法带 Authorization 头,接口未认证直接 401,重定向逻辑根本没机会执行。 改为前端先用 authFetch 认证请求,再按返回类型跳转远端地址或保存二 进制文件;远端地址同时改造成与 IPA 一致的 OSS 签名短时链接,避免 未签名地址在私有 bucket 下被拒绝。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
419 lines
17 KiB
Python
419 lines
17 KiB
Python
"""任务 API"""
|
|
import os
|
|
import json
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import List
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
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)])
|
|
|
|
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
|
|
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
|
|
_recent_download_logs: dict[str, float] = {}
|
|
|
|
|
|
@router.post("", response_model=TaskResponse)
|
|
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), 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]
|
|
scheme_name = scheme.get("name", "")
|
|
|
|
if task.build_type not in {"Ad_Hoc", "App_Store"}:
|
|
raise HTTPException(status_code=400, detail="不支持的打包类型")
|
|
if not app.get("certificates", {}).get(task.build_type):
|
|
raise HTTPException(status_code=400, detail=f"该 App 未配置 {task.build_type} 证书")
|
|
|
|
from .apps import get_allowed_scheme_names
|
|
allowed_scheme_names = get_allowed_scheme_names(app)
|
|
if allowed_scheme_names and scheme_name not in allowed_scheme_names:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
|
|
)
|
|
|
|
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_name,
|
|
obfuscation=task.obfuscation,
|
|
branch=task.branch,
|
|
status="pending",
|
|
)
|
|
db.add(db_task)
|
|
from ..services.audit_log import record_operation
|
|
record_operation(db, user, "build_created", request=request, task=db_task,
|
|
detail={"branch": task.branch, "build_type": task.build_type})
|
|
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,
|
|
include_superseded: bool = False,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""获取任务列表"""
|
|
query = db.query(Task)
|
|
if include_superseded:
|
|
if not user.get("is_admin"):
|
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
|
else:
|
|
query = query.filter(Task.superseded_at.is_(None))
|
|
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, request: Request, user: dict = Depends(get_current_user), 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"
|
|
from ..services.audit_log import record_operation
|
|
record_operation(db, user, "build_cancelled", request=request, task=task)
|
|
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, request: Request, user: dict = Depends(get_current_user), 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="任务正在运行中,无法删除")
|
|
|
|
# 同一个 App / 版本 / 分支重复打包会覆盖并复用同一个远端文件;
|
|
# 只要仍有其他历史记录引用该下载地址,就不能删除远端产物。
|
|
has_shared_artifact = bool(
|
|
task.oss_url and db.query(Task).filter(
|
|
Task.id != task.id,
|
|
Task.oss_url == task.oss_url,
|
|
).first()
|
|
)
|
|
if task.oss_url and not has_shared_artifact:
|
|
if not task.config_json:
|
|
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端文件")
|
|
try:
|
|
build_config = json.loads(task.config_json)
|
|
from .config import load_config
|
|
from ..services.distribution import DistributionError, delete_published_artifacts
|
|
delete_published_artifacts(
|
|
build_config,
|
|
load_config().get("upload", {}),
|
|
task.oss_url,
|
|
)
|
|
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():
|
|
log_path.unlink()
|
|
|
|
# 删除构建目录
|
|
if task.build_dir:
|
|
build_path = Path(task.build_dir)
|
|
if build_path.exists():
|
|
shutil.rmtree(build_path, ignore_errors=True)
|
|
|
|
from ..services.audit_log import record_operation
|
|
record_operation(db, user, "task_deleted", request=request, task=task)
|
|
db.delete(task)
|
|
db.commit()
|
|
|
|
message = "记录已删除"
|
|
if task.oss_url:
|
|
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
|
|
return {"message": 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}/download-link")
|
|
async def get_download_link(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
"""记录已登录用户发起 IPA 下载,并返回 OSS 公开链接。"""
|
|
task = db.query(Task).filter(Task.id == task_id).first()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="任务不存在")
|
|
if not task.oss_url:
|
|
raise HTTPException(status_code=404, detail="下载文件不存在")
|
|
try:
|
|
from .config import load_config
|
|
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
|
|
from ..services.distribution import DistributionError, get_published_download_url
|
|
download_url = get_published_download_url(
|
|
json.loads(task.config_json or "{}"),
|
|
load_config().get("upload", {}),
|
|
task.oss_url,
|
|
)
|
|
except (json.JSONDecodeError, DistributionError) as exc:
|
|
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
|
|
|
|
debounce_key = f"{user.get('username')}:{task_id}"
|
|
now = time.monotonic()
|
|
last = _recent_download_logs.get(debounce_key)
|
|
if last is None or now - last > _DOWNLOAD_LOG_DEBOUNCE_SECONDS:
|
|
if len(_recent_download_logs) > 500:
|
|
cutoff = now - _DOWNLOAD_LOG_DEBOUNCE_SECONDS
|
|
for key, ts in list(_recent_download_logs.items()):
|
|
if ts < cutoff:
|
|
del _recent_download_logs[key]
|
|
_recent_download_logs[debounce_key] = now
|
|
from ..services.audit_log import record_operation
|
|
record_operation(db, user, "ipa_download_requested", request=request, task=task,
|
|
resource_type="ipa", resource_name=task.oss_url,
|
|
detail={"controlled": True})
|
|
db.commit()
|
|
return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS}
|
|
|
|
|
|
@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 文件:本地文件已被清理时回退到构建时上传保存的远端地址"""
|
|
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="任务不存在")
|
|
|
|
dsym_path = task.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:
|
|
# 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):
|
|
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, request: Request, user: dict = Depends(get_current_user), 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="混淆映射表不存在")
|
|
from ..services.audit_log import record_operation
|
|
record_operation(db, user, "obfuscation_maps_downloaded", request=request, task=task,
|
|
resource_type="obfuscation_maps", resource_name=os.path.basename(task.obfuscation_maps_path))
|
|
db.commit()
|
|
|
|
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")
|