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:
shen
2026-06-06 17:42:27 +08:00
commit 6f4f625c56
51 changed files with 9968 additions and 0 deletions
View File
+27
View File
@@ -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"])
+19
View File
@@ -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="用户名或密码错误")
+398
View File
@@ -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": "服务器环境已删除"}
+158
View File
@@ -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")