Initial commit: iOS Build Server
- FastAPI backend with build queue, WebSocket logs, task management - Vue 3 frontend with build/config/history views - Xcode project build automation with IPA export - Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""打包选择 API"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .config import load_config
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["apps"])
|
||||
|
||||
|
||||
@router.get("/apps")
|
||||
async def get_apps_for_build():
|
||||
"""获取 apps 列表(供打包选择)"""
|
||||
config = load_config()
|
||||
return config.get("apps", {})
|
||||
|
||||
|
||||
@router.get("/schemes")
|
||||
async def get_schemes_for_build():
|
||||
"""获取 schemes 列表(供打包选择)"""
|
||||
config = load_config()
|
||||
return config.get("schemes", {})
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def get_branches_for_build():
|
||||
"""获取分支列表(供打包选择)"""
|
||||
config = load_config()
|
||||
return config.get("branches", ["main"])
|
||||
@@ -0,0 +1,19 @@
|
||||
"""认证 API"""
|
||||
from fastapi import APIRouter
|
||||
from ..schemas import LoginRequest, LoginResponse
|
||||
from ..config import ADMIN_USERNAME, ADMIN_PASSWORD
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: LoginRequest):
|
||||
"""管理员登录"""
|
||||
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
|
||||
return LoginResponse(
|
||||
token="admin-token",
|
||||
username=request.username,
|
||||
is_admin=True
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
@@ -0,0 +1,398 @@
|
||||
"""配置管理 API"""
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import BuildConfig
|
||||
from ..schemas import BuildConfigUpdate
|
||||
from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
_config_lock = asyncio.Lock()
|
||||
|
||||
# 默认上传配置
|
||||
DEFAULT_UPLOAD = {
|
||||
"mode": "oss",
|
||||
"oss": {
|
||||
"access_key_id": "",
|
||||
"access_key_secret": "",
|
||||
"endpoint": "oss-cn-beijing.aliyuncs.com",
|
||||
"bucket_name": "",
|
||||
"base_url": "",
|
||||
},
|
||||
"webdav": {
|
||||
"server_url": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"base_path": "/ios-builds",
|
||||
"public_url": "",
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": False,
|
||||
"webhook_url": "",
|
||||
"secret": "",
|
||||
},
|
||||
}
|
||||
|
||||
# 默认服务器环境配置
|
||||
DEFAULT_SERVERS = {
|
||||
"测试环境": {
|
||||
"api": "https://api3-dev.readoor.cn",
|
||||
"assDom": "applinks:dev-data1.readoor.cn",
|
||||
"universalLink": "https://dev-data1.readoor.cn"
|
||||
},
|
||||
"正式环境": {
|
||||
"api": "https://api3.readoor.cn",
|
||||
"assDom": "applinks:data1.readoor.cn",
|
||||
"universalLink": "https://data1.readoor.cn"
|
||||
},
|
||||
"华师大环境": {
|
||||
"api": "https://api3.ecnupress.com.cn",
|
||||
"assDom": "applinks:data1.ecnupress.com.cn",
|
||||
"universalLink": "https://data1.ecnupress.com.cn"
|
||||
},
|
||||
"外教环境": {
|
||||
"api": "https://weread-api3.sflep.com/api3",
|
||||
"assDom": "applinks:wereadossda.sflep.com",
|
||||
"universalLink": "https://wereadossda.sflep.com"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""读取 config.json"""
|
||||
if not CONFIG_JSON_PATH.exists():
|
||||
return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD}
|
||||
with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
# 确保必要字段存在
|
||||
if "servers" not in config:
|
||||
config["servers"] = DEFAULT_SERVERS
|
||||
if "branches" not in config:
|
||||
config["branches"] = ["main"]
|
||||
if "upload" not in config:
|
||||
config["upload"] = DEFAULT_UPLOAD
|
||||
return config
|
||||
|
||||
|
||||
def save_config(config: dict):
|
||||
"""保存 config.json(自动备份)"""
|
||||
if CONFIG_JSON_PATH.exists():
|
||||
backup_path = CONFIG_JSON_PATH.with_suffix(".json.bak")
|
||||
shutil.copy2(CONFIG_JSON_PATH, backup_path)
|
||||
with open(CONFIG_JSON_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def get_build_config_value(db: Session, key: str, default=None) -> str:
|
||||
"""获取打包配置值"""
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == key).first()
|
||||
return config.value if config else default
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_full_config():
|
||||
"""获取完整 config.json"""
|
||||
return load_config()
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_full_config(config: dict):
|
||||
"""更新整个 config.json"""
|
||||
async with _config_lock:
|
||||
save_config(config)
|
||||
return {"message": "配置已更新"}
|
||||
|
||||
|
||||
@router.get("/apps")
|
||||
async def get_apps():
|
||||
"""获取 apps 配置"""
|
||||
config = load_config()
|
||||
return config.get("apps", {})
|
||||
|
||||
|
||||
@router.post("/apps")
|
||||
async def create_app(app: dict):
|
||||
"""新增 app"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
# 自动生成 ID
|
||||
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
|
||||
apps[new_id] = app
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
return {"id": new_id, "message": "App 已创建"}
|
||||
|
||||
|
||||
@router.put("/apps/{app_id}")
|
||||
async def update_app(app_id: str, app: dict):
|
||||
"""更新指定 app"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
if app_id not in apps:
|
||||
raise HTTPException(status_code=404, detail="App 不存在")
|
||||
|
||||
apps[app_id] = app
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
return {"message": "App 已更新"}
|
||||
|
||||
|
||||
@router.delete("/apps/{app_id}")
|
||||
async def delete_app(app_id: str):
|
||||
"""删除指定 app"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
if app_id not in apps:
|
||||
raise HTTPException(status_code=404, detail="App 不存在")
|
||||
|
||||
del apps[app_id]
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
return {"message": "App 已删除"}
|
||||
|
||||
|
||||
@router.get("/schemes")
|
||||
async def get_schemes():
|
||||
"""获取 schemes 配置"""
|
||||
config = load_config()
|
||||
return config.get("schemes", {})
|
||||
|
||||
|
||||
@router.post("/schemes")
|
||||
async def create_scheme(scheme: dict):
|
||||
"""新增 scheme"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
numeric_keys = [int(k) for k in schemes.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
|
||||
schemes[new_id] = scheme
|
||||
config["schemes"] = schemes
|
||||
save_config(config)
|
||||
return {"id": new_id, "message": "Scheme 已创建"}
|
||||
|
||||
|
||||
@router.put("/schemes/{scheme_id}")
|
||||
async def update_scheme(scheme_id: str, scheme: dict):
|
||||
"""更新指定 scheme"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
if scheme_id not in schemes:
|
||||
raise HTTPException(status_code=404, detail="Scheme 不存在")
|
||||
|
||||
schemes[scheme_id] = scheme
|
||||
config["schemes"] = schemes
|
||||
save_config(config)
|
||||
return {"message": "Scheme 已更新"}
|
||||
|
||||
|
||||
@router.delete("/schemes/{scheme_id}")
|
||||
async def delete_scheme(scheme_id: str):
|
||||
"""删除指定 scheme"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
if scheme_id not in schemes:
|
||||
raise HTTPException(status_code=404, detail="Scheme 不存在")
|
||||
|
||||
del schemes[scheme_id]
|
||||
config["schemes"] = schemes
|
||||
save_config(config)
|
||||
return {"message": "Scheme 已删除"}
|
||||
|
||||
|
||||
# 分支管理 API
|
||||
@router.get("/branches")
|
||||
async def get_branches():
|
||||
"""获取分支列表"""
|
||||
config = load_config()
|
||||
return config.get("branches", ["main"])
|
||||
|
||||
|
||||
@router.post("/branches")
|
||||
async def add_branch(data: dict):
|
||||
"""新增分支"""
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="分支名称不能为空")
|
||||
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
branches = config.get("branches", ["main"])
|
||||
if name in branches:
|
||||
raise HTTPException(status_code=400, detail="分支已存在")
|
||||
branches.append(name)
|
||||
config["branches"] = branches
|
||||
save_config(config)
|
||||
return {"message": "分支已添加"}
|
||||
|
||||
|
||||
@router.delete("/branches/{branch_name}")
|
||||
async def delete_branch(branch_name: str):
|
||||
"""删除分支"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
branches = config.get("branches", ["main"])
|
||||
if branch_name not in branches:
|
||||
raise HTTPException(status_code=404, detail="分支不存在")
|
||||
branches.remove(branch_name)
|
||||
config["branches"] = branches
|
||||
save_config(config)
|
||||
return {"message": "分支已删除"}
|
||||
|
||||
|
||||
@router.get("/build")
|
||||
async def get_build_settings(db: Session = Depends(get_db)):
|
||||
"""获取打包设置"""
|
||||
return {
|
||||
"max_concurrent_builds": int(get_build_config_value(db, "max_concurrent_builds", DEFAULT_MAX_CONCURRENT_BUILDS)),
|
||||
"build_dir_retention_hours": int(get_build_config_value(db, "build_dir_retention_hours", DEFAULT_BUILD_DIR_RETENTION_HOURS)),
|
||||
"build_base_dir": get_build_config_value(db, "build_base_dir", "/Users/shen/Documents"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/build")
|
||||
async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depends(get_db)):
|
||||
"""更新打包设置"""
|
||||
if settings.max_concurrent_builds is not None:
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "max_concurrent_builds").first()
|
||||
if config:
|
||||
config.value = str(settings.max_concurrent_builds)
|
||||
else:
|
||||
db.add(BuildConfig(key="max_concurrent_builds", value=str(settings.max_concurrent_builds)))
|
||||
|
||||
if settings.build_dir_retention_hours is not None:
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
if config:
|
||||
config.value = str(settings.build_dir_retention_hours)
|
||||
else:
|
||||
db.add(BuildConfig(key="build_dir_retention_hours", value=str(settings.build_dir_retention_hours)))
|
||||
|
||||
if settings.build_base_dir is not None:
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_base_dir").first()
|
||||
if config:
|
||||
config.value = settings.build_base_dir
|
||||
else:
|
||||
db.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir))
|
||||
|
||||
db.commit()
|
||||
return {"message": "打包设置已更新"}
|
||||
|
||||
|
||||
# 上传配置 API
|
||||
@router.get("/upload")
|
||||
async def get_upload_config():
|
||||
"""获取上传配置"""
|
||||
config = load_config()
|
||||
return config.get("upload", DEFAULT_UPLOAD)
|
||||
|
||||
|
||||
@router.put("/upload")
|
||||
async def update_upload_config(upload_data: dict):
|
||||
"""更新上传配置"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
config["upload"] = upload_data
|
||||
save_config(config)
|
||||
return {"message": "上传配置已更新"}
|
||||
|
||||
|
||||
# 服务器环境管理 API
|
||||
@router.get("/servers")
|
||||
async def get_servers():
|
||||
"""获取服务器环境配置"""
|
||||
config = load_config()
|
||||
return config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
|
||||
@router.post("/servers")
|
||||
async def create_server(server_data: dict):
|
||||
"""新增服务器环境"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
servers = config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
name = server_data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="环境名称不能为空")
|
||||
if name in servers:
|
||||
raise HTTPException(status_code=400, detail="环境名称已存在")
|
||||
|
||||
servers[name] = {
|
||||
"api": server_data.get("api", ""),
|
||||
"assDom": server_data.get("assDom", ""),
|
||||
"universalLink": server_data.get("universalLink", ""),
|
||||
}
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已创建"}
|
||||
|
||||
|
||||
@router.put("/servers/{server_name}")
|
||||
async def update_server(server_name: str, server_data: dict):
|
||||
"""更新服务器环境"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
servers = config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
if server_name not in servers:
|
||||
raise HTTPException(status_code=404, detail="环境不存在")
|
||||
|
||||
# 如果名称变了,需要删除旧的
|
||||
new_name = server_data.get("name", server_name).strip()
|
||||
if new_name != server_name:
|
||||
if new_name in servers:
|
||||
raise HTTPException(status_code=400, detail="新环境名称已存在")
|
||||
servers[new_name] = servers.pop(server_name)
|
||||
else:
|
||||
new_name = server_name
|
||||
|
||||
servers[new_name] = {
|
||||
"api": server_data.get("api", ""),
|
||||
"assDom": server_data.get("assDom", ""),
|
||||
"universalLink": server_data.get("universalLink", ""),
|
||||
}
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已更新"}
|
||||
|
||||
|
||||
@router.delete("/servers/{server_name}")
|
||||
async def delete_server(server_name: str):
|
||||
"""删除服务器环境"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
servers = config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
if server_name not in servers:
|
||||
raise HTTPException(status_code=404, detail="环境不存在")
|
||||
|
||||
# 检查是否有 App 在使用此环境
|
||||
apps = config.get("apps", {})
|
||||
for app in apps.values():
|
||||
if app.get("server") == server_name:
|
||||
raise HTTPException(status_code=400, detail=f"无法删除:有 App 正在使用此环境")
|
||||
|
||||
del servers[server_name]
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已删除"}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""任务 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
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@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("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
|
||||
|
||||
|
||||
@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 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 task
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
return {"message": "任务已取消"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/dsym")
|
||||
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
||||
"""下载 dSYM 文件"""
|
||||
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):
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
|
||||
|
||||
return FileResponse(task.dsym_path, filename=os.path.basename(task.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")
|
||||
Reference in New Issue
Block a user