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,67 @@
|
||||
"""项目配置 — 从环境变量读取,支持 .env 文件"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 自动加载 .env 文件(如果存在)
|
||||
_env_file = Path(__file__).parent.parent / ".env"
|
||||
if _env_file.exists():
|
||||
with open(_env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
# 项目根目录(iOS 源码,也作为 AutoPacking 脚本来源)
|
||||
PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "/Users/shen/Work/Code/Readoor"))
|
||||
|
||||
# AutoPacking 目录
|
||||
AUTOPACKING_DIR = PROJECT_ROOT / "AutoPacking"
|
||||
|
||||
# config.json 路径
|
||||
CONFIG_JSON_PATH = AUTOPACKING_DIR / "config.json"
|
||||
|
||||
# 打包基础目录
|
||||
BUILD_BASE_DIR = Path(os.getenv("BUILD_BASE_DIR", "/Users/shen/Documents"))
|
||||
|
||||
# Git 分支源码目录
|
||||
# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录
|
||||
# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone
|
||||
GIT_SOURCE_BASE = Path(os.getenv("GIT_SOURCE_BASE", str(PROJECT_ROOT.parent / "ReadoorBranches")))
|
||||
GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "")
|
||||
|
||||
|
||||
def get_source_dir(branch: str) -> Path:
|
||||
"""获取指定分支的源码目录"""
|
||||
return GIT_SOURCE_BASE / branch
|
||||
|
||||
# 需要拷贝的目录和文件
|
||||
COPY_ITEMS = [
|
||||
"readoor",
|
||||
"readoor.xcodeproj",
|
||||
"readoorTests",
|
||||
"AutoPacking",
|
||||
"podfile",
|
||||
"Pods",
|
||||
"Podfile.lock",
|
||||
"readoor.xcworkspace",
|
||||
]
|
||||
|
||||
# 服务端口
|
||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}"
|
||||
|
||||
# WebSocket 日志队列最大长度
|
||||
LOG_QUEUE_MAX_SIZE = 1000
|
||||
|
||||
# 默认最大并行打包数
|
||||
DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2"))
|
||||
|
||||
# 默认打包目录保留时间(小时)
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24"))
|
||||
|
||||
# 管理员账号配置
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from .config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _migrate_db():
|
||||
"""简单的数据库迁移:为旧表添加缺失的列"""
|
||||
migrations = [
|
||||
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # 列已存在则忽略
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_db()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""FastAPI 主入口"""
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from .database import init_db
|
||||
from .routers import config, apps, tasks, auth
|
||||
from .services.log_streamer import log_streamer
|
||||
from .config import BACKEND_PORT
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期"""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="iOS 自动打包服务",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(apps.router)
|
||||
app.include_router(tasks.router)
|
||||
|
||||
|
||||
@app.websocket("/ws/tasks/{task_id}")
|
||||
async def websocket_logs(websocket: WebSocket, task_id: str):
|
||||
"""WebSocket 实时日志"""
|
||||
await websocket.accept()
|
||||
try:
|
||||
async for msg in log_streamer.subscribe(task_id):
|
||||
await websocket.send_json(msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
"""健康检查"""
|
||||
return {"status": "ok", "port": BACKEND_PORT}
|
||||
|
||||
|
||||
# 静态文件服务(生产模式:前端 build 产物由 FastAPI 直接 serving)
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
"""SPA catch-all:非 API 路由统一返回 index.html"""
|
||||
file_path = STATIC_DIR / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(str(file_path))
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
@@ -0,0 +1,50 @@
|
||||
"""数据库模型"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, Text, TIMESTAMP
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""打包任务表"""
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
app_id = Column(String, nullable=False)
|
||||
app_name = Column(String, nullable=False)
|
||||
build_type = Column(String, nullable=False)
|
||||
scheme_id = Column(String, nullable=False)
|
||||
scheme_name = Column(String, nullable=False)
|
||||
obfuscation = Column(Boolean, default=False)
|
||||
branch = Column(String, default="main")
|
||||
|
||||
# 状态
|
||||
status = Column(String, default="pending") # pending/running/completed/failed/cancelled
|
||||
current_step = Column(String)
|
||||
|
||||
# 时间
|
||||
created_at = Column(TIMESTAMP, default=datetime.utcnow)
|
||||
started_at = Column(TIMESTAMP)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
|
||||
# 产物
|
||||
ipa_path = Column(String)
|
||||
oss_url = Column(String)
|
||||
dsym_path = Column(String)
|
||||
obfuscation_maps_path = Column(String)
|
||||
qr_code_path = Column(String)
|
||||
build_dir = Column(String)
|
||||
|
||||
# 错误
|
||||
error_message = Column(Text)
|
||||
|
||||
# 配置快照
|
||||
config_json = Column(Text)
|
||||
|
||||
|
||||
class BuildConfig(Base):
|
||||
"""打包配置表"""
|
||||
__tablename__ = "build_config"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(String)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Pydantic 模型"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# 打包任务
|
||||
class TaskCreate(BaseModel):
|
||||
app_id: str
|
||||
build_type: str
|
||||
scheme_id: str
|
||||
obfuscation: bool = False
|
||||
branch: str = "main"
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
id: str
|
||||
app_id: str
|
||||
app_name: str
|
||||
build_type: str
|
||||
scheme_id: str
|
||||
scheme_name: str
|
||||
obfuscation: bool
|
||||
branch: str
|
||||
status: str
|
||||
current_step: Optional[str]
|
||||
created_at: Optional[datetime]
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
ipa_path: Optional[str]
|
||||
oss_url: Optional[str]
|
||||
dsym_path: Optional[str]
|
||||
obfuscation_maps_path: Optional[str]
|
||||
qr_code_path: Optional[str]
|
||||
build_dir: Optional[str]
|
||||
error_message: Optional[str]
|
||||
config_json: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 登录
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
# 打包配置
|
||||
class BuildConfigUpdate(BaseModel):
|
||||
max_concurrent_builds: Optional[int] = None
|
||||
build_dir_retention_hours: Optional[int] = None
|
||||
build_base_dir: Optional[str] = None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""并行打包队列管理"""
|
||||
import asyncio
|
||||
from typing import Dict, Callable, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from .log_streamer import log_streamer
|
||||
|
||||
|
||||
class BuildQueue:
|
||||
"""打包任务队列"""
|
||||
|
||||
def __init__(self, max_concurrent: int = 2):
|
||||
self._max_concurrent = max_concurrent
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._queue: asyncio.Queue = asyncio.Queue()
|
||||
self._running_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._workers: list = []
|
||||
self._started = False
|
||||
|
||||
async def start(self):
|
||||
"""启动工作线程"""
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
for i in range(self._max_concurrent):
|
||||
worker = asyncio.create_task(self._worker(f"worker-{i}"))
|
||||
self._workers.append(worker)
|
||||
|
||||
async def _worker(self, name: str):
|
||||
"""工作协程"""
|
||||
while True:
|
||||
task_id, build_func = await self._queue.get()
|
||||
try:
|
||||
await self._semaphore.acquire()
|
||||
try:
|
||||
self._running_tasks[task_id] = asyncio.current_task()
|
||||
await build_func(task_id)
|
||||
finally:
|
||||
self._running_tasks.pop(task_id, None)
|
||||
self._semaphore.release()
|
||||
except Exception as e:
|
||||
await log_streamer.emit_error(task_id, f"任务异常: {str(e)}")
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
async def submit(self, task_id: str, build_func: Optional[Callable] = None):
|
||||
"""提交打包任务"""
|
||||
if not self._started:
|
||||
await self.start()
|
||||
|
||||
if build_func is None:
|
||||
from .build_service import run_build_task
|
||||
build_func = run_build_task
|
||||
|
||||
await self._queue.put((task_id, build_func))
|
||||
await log_streamer.emit(task_id, "任务已加入队列,等待执行...")
|
||||
|
||||
def cancel(self, task_id: str):
|
||||
"""取消任务"""
|
||||
task = self._running_tasks.get(task_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
|
||||
def update_max_concurrent(self, new_max: int):
|
||||
"""更新最大并发数"""
|
||||
self._max_concurrent = new_max
|
||||
# 注意:运行时修改需要重建 semaphore,这里简化处理
|
||||
# 实际使用时建议重启服务生效
|
||||
|
||||
@property
|
||||
def queue_size(self) -> int:
|
||||
return self._queue.qsize()
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
return len(self._running_tasks)
|
||||
|
||||
|
||||
# 全局单例
|
||||
build_queue = BuildQueue()
|
||||
@@ -0,0 +1,590 @@
|
||||
"""打包服务核心"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import (
|
||||
PROJECT_ROOT,
|
||||
AUTOPACKING_DIR,
|
||||
BUILD_BASE_DIR,
|
||||
COPY_ITEMS,
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||||
GIT_REMOTE_URL,
|
||||
get_source_dir,
|
||||
)
|
||||
from .log_streamer import log_streamer
|
||||
|
||||
|
||||
def _cleanup_build_dir(build_dir: Path):
|
||||
"""删除整个打包目录"""
|
||||
try:
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_old_builds(db):
|
||||
"""清理超过保留时间的打包目录"""
|
||||
from ..models import BuildConfig
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
hours = int(config.value) if config else DEFAULT_BUILD_DIR_RETENTION_HOURS
|
||||
cutoff = datetime.utcnow().timestamp() - hours * 3600
|
||||
|
||||
base = BUILD_BASE_DIR
|
||||
if not base.exists():
|
||||
return
|
||||
for d in base.iterdir():
|
||||
if d.is_dir() and d.name.startswith("build_readoor_"):
|
||||
try:
|
||||
if d.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _db_update(db, task, **fields):
|
||||
"""同步更新 task 字段并 commit(在线程中调用)"""
|
||||
for k, v in fields.items():
|
||||
setattr(task, k, v)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def update_source(task_id: str, source_dir: Path, branch: str):
|
||||
"""确保分支源码目录存在且为最新"""
|
||||
if not source_dir.exists():
|
||||
# 首次:从远程 clone
|
||||
if not GIT_REMOTE_URL:
|
||||
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
|
||||
source_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
await log_streamer.emit(task_id, f"克隆仓库: {GIT_REMOTE_URL} → {branch}")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "clone", "-b", branch, "--single-branch", GIT_REMOTE_URL, str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"git clone 失败: {branch}")
|
||||
else:
|
||||
# 已存在:fetch + checkout + pull
|
||||
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
|
||||
|
||||
# fetch
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "fetch", "origin",
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception("git fetch 失败")
|
||||
|
||||
# checkout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "checkout", branch,
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"git checkout {branch} 失败")
|
||||
|
||||
# pull
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "pull", "origin", branch,
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"git pull {branch} 失败")
|
||||
|
||||
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
|
||||
|
||||
|
||||
async def run_build_task(task_id: str):
|
||||
"""执行打包任务"""
|
||||
from ..database import SessionLocal
|
||||
from ..models import Task
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = await asyncio.to_thread(
|
||||
lambda: db.query(Task).filter(Task.id == task_id).first()
|
||||
)
|
||||
if not task:
|
||||
return
|
||||
|
||||
# 创建日志队列(必须在 emit 之前)
|
||||
log_streamer.create_queue(task_id)
|
||||
|
||||
# 更新状态
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="running", started_at=datetime.utcnow())
|
||||
|
||||
await log_streamer.emit_step(task_id, "开始打包")
|
||||
|
||||
# 清理过期的打包目录
|
||||
await asyncio.to_thread(_cleanup_old_builds, db)
|
||||
|
||||
build_dir = None
|
||||
try:
|
||||
# 1. 更新分支源码并拷贝
|
||||
source_dir = get_source_dir(task.branch)
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
||||
if GIT_REMOTE_URL:
|
||||
# 配置了远程仓库,从分支目录获取源码
|
||||
await update_source(task_id, source_dir, task.branch)
|
||||
elif source_dir.exists():
|
||||
# 分支目录已存在,直接使用
|
||||
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
||||
else:
|
||||
# 未配置远程仓库且分支目录不存在,使用默认源码目录
|
||||
source_dir = PROJECT_ROOT
|
||||
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL,使用默认源码: {source_dir}")
|
||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||
|
||||
# 2. 生成配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
||||
config_data = await generate_config(task_id, task, build_dir)
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
config_json=json.dumps(config_data, ensure_ascii=False))
|
||||
|
||||
# 3. 替换项目配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="patch")
|
||||
await patch_project(task_id, task, config_data, build_dir)
|
||||
|
||||
# 4. 代码混淆(可选)
|
||||
if task.obfuscation:
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
|
||||
await run_obfuscation(task_id, task, build_dir, source_dir)
|
||||
|
||||
# 5. 构建项目
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="build")
|
||||
ipa_path = await build_project(task_id, task, build_dir)
|
||||
|
||||
# 查找 dSYM
|
||||
dsym_path = await find_dsym(task_id, build_dir)
|
||||
|
||||
# 查找混淆映射表
|
||||
obf_maps_path = build_dir / "obfuscation_maps"
|
||||
|
||||
update_fields = {"ipa_path": str(ipa_path)}
|
||||
if dsym_path:
|
||||
update_fields["dsym_path"] = str(dsym_path)
|
||||
if obf_maps_path.exists():
|
||||
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
|
||||
await asyncio.to_thread(_db_update, db, task, **update_fields)
|
||||
|
||||
# 6. 上传分发(仅 Ad_Hoc)
|
||||
if task.build_type == "Ad_Hoc":
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="upload")
|
||||
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path)
|
||||
upload_fields = {}
|
||||
if oss_url:
|
||||
upload_fields["oss_url"] = oss_url
|
||||
if qr_code_path:
|
||||
upload_fields["qr_code_path"] = qr_code_path
|
||||
if upload_fields:
|
||||
await asyncio.to_thread(_db_update, db, task, **upload_fields)
|
||||
|
||||
# 完成
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="completed", completed_at=datetime.utcnow(),
|
||||
current_step=None)
|
||||
|
||||
await log_streamer.emit_step(task_id, "打包完成")
|
||||
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
|
||||
if task.oss_url:
|
||||
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
|
||||
|
||||
# 清理打包目录(保留产物文件,删除源码拷贝)
|
||||
await log_streamer.emit(task_id, "清理临时文件...")
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="cancelled", completed_at=datetime.utcnow())
|
||||
await log_streamer.emit(task_id, "任务已取消")
|
||||
if build_dir and build_dir.exists():
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
except Exception as e:
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="failed", completed_at=datetime.utcnow(),
|
||||
error_message=str(e))
|
||||
await log_streamer.emit_error(task_id, f"打包失败: {str(e)}")
|
||||
if build_dir and build_dir.exists():
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
finally:
|
||||
log_streamer.complete(task_id)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def copy_source_code(task_id: str, task, source_dir: Path) -> Path:
|
||||
"""从分支源码目录拷贝代码到打包目录"""
|
||||
await log_streamer.emit_step(task_id, "拷贝代码")
|
||||
|
||||
build_dir = BUILD_BASE_DIR / f"build_readoor_{task_id}"
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
build_dir.mkdir(parents=True)
|
||||
|
||||
for item in COPY_ITEMS:
|
||||
src = source_dir / item
|
||||
dst = build_dir / item
|
||||
if src.is_dir():
|
||||
await log_streamer.emit(task_id, f"拷贝目录: {item}")
|
||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".git", "DerivedData"))
|
||||
elif src.is_file():
|
||||
await log_streamer.emit(task_id, f"拷贝文件: {item}")
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
await log_streamer.emit(task_id, "代码拷贝完成")
|
||||
return build_dir
|
||||
|
||||
|
||||
async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
"""生成打包配置"""
|
||||
await log_streamer.emit_step(task_id, "生成配置")
|
||||
|
||||
from ..routers.config import load_config
|
||||
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
app = apps.get(task.app_id, {})
|
||||
scheme = schemes.get(task.scheme_id, {})
|
||||
|
||||
# 读取版本号
|
||||
start_build_path = AUTOPACKING_DIR / "start_build_app.py"
|
||||
app_ver = "2.180.0"
|
||||
build_ver = "2.180.0.0"
|
||||
|
||||
if start_build_path.exists():
|
||||
content = start_build_path.read_text()
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("App_Ver"):
|
||||
app_ver = line.split('"')[1] if '"' in line else app_ver
|
||||
elif line.startswith("Build_Ver"):
|
||||
build_ver = line.split('"')[1] if '"' in line else build_ver
|
||||
|
||||
# 构建配置
|
||||
config_data = {
|
||||
"VERSION": app_ver,
|
||||
"BUILD_VERSION": build_ver,
|
||||
"SERVER": app.get("server", ""),
|
||||
"API": app.get("API", ""),
|
||||
"APPID_NAME": app.get("name", ""),
|
||||
"APPID": app.get("AppGuid", app.get("AppId", "")),
|
||||
"SCHEME": scheme.get("name", ""),
|
||||
"OSS_FLODER": scheme.get("ossFloder", ""),
|
||||
"BUILD_TYPE": task.build_type,
|
||||
"ENABLE_OBFUSCATION": task.obfuscation,
|
||||
}
|
||||
|
||||
# 证书配置
|
||||
certificates = app.get("certificates", {})
|
||||
cert = certificates.get(task.build_type, {})
|
||||
|
||||
if cert:
|
||||
config_data["CERTIFICATE"] = cert.get("cer", "")
|
||||
config_data["PROVISIONING_PROFILE"] = cert.get("pro", "")
|
||||
config_data["BUNDLE_ID"] = cert.get("name", "")
|
||||
config_data["THEME"] = cert.get("theme", "")
|
||||
|
||||
# 其他配置
|
||||
for key in ["weixinlogin", "weixinpay", "tencent", "AssDom", "UniversalLink", "AlivcLicenseKey"]:
|
||||
if key in app:
|
||||
config_data[key] = app[key]
|
||||
|
||||
# 写入配置文件
|
||||
config_output_path = build_dir / "config_output.json"
|
||||
with open(config_output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
await log_streamer.emit(task_id, "配置已生成")
|
||||
return config_data
|
||||
|
||||
|
||||
async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
|
||||
"""替换项目配置"""
|
||||
await log_streamer.emit_step(task_id, "替换项目配置")
|
||||
|
||||
# 根据 scheme 选择不同的替换逻辑
|
||||
if config_data.get("SCHEME") == "readoorDict":
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
"--dict-config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
"--config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
|
||||
# 使用 subprocess 执行替换
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("项目配置替换失败")
|
||||
|
||||
await log_streamer.emit(task_id, "项目配置替换完成")
|
||||
|
||||
|
||||
async def run_obfuscation(task_id: str, task, build_dir: Path, source_root: Path):
|
||||
"""执行代码混淆"""
|
||||
await log_streamer.emit_step(task_id, "代码混淆")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TARGET_NAME"] = task.scheme_name
|
||||
env["SKIP_OBF_PHASE"] = "1"
|
||||
|
||||
# 自检
|
||||
self_check_script = AUTOPACKING_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bash", str(self_check_script), str(source_root),
|
||||
cwd=str(build_dir),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("混淆自检失败")
|
||||
|
||||
await log_streamer.emit(task_id, "混淆自检通过")
|
||||
|
||||
# 执行混淆
|
||||
obfuscate_script = AUTOPACKING_DIR / "obfuscation" / "obfuscate_symbols.py"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"python3", str(obfuscate_script), "--archive-mode",
|
||||
cwd=str(build_dir),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("代码混淆失败")
|
||||
|
||||
await log_streamer.emit(task_id, "代码混淆完成")
|
||||
|
||||
|
||||
async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
||||
"""构建项目"""
|
||||
await log_streamer.emit_step(task_id, "构建项目")
|
||||
|
||||
scheme = task.scheme_name
|
||||
export_path = build_dir / "build"
|
||||
archive_path = export_path / f"{scheme}.xcarchive"
|
||||
workspace_path = build_dir / "readoor.xcworkspace"
|
||||
|
||||
export_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 清理
|
||||
await log_streamer.emit(task_id, "清理项目...")
|
||||
clean_cmd = (
|
||||
f"xcodebuild clean -workspace {workspace_path.name} "
|
||||
f"-scheme {scheme} "
|
||||
f"-configuration Release "
|
||||
f"-derivedDataPath {export_path / 'derived_data'}"
|
||||
)
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
clean_cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
# Archive
|
||||
await log_streamer.emit(task_id, "开始 Archive...")
|
||||
archive_cmd = (
|
||||
f"xcodebuild archive -workspace {workspace_path.name} "
|
||||
f"-scheme {scheme} "
|
||||
f"-configuration Release "
|
||||
f"-archivePath {archive_path} "
|
||||
f"-derivedDataPath {export_path / 'derived_data'} "
|
||||
f"-destination generic/platform=ios -quiet"
|
||||
)
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
archive_cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("Archive 失败")
|
||||
|
||||
# 导出 IPA
|
||||
await log_streamer.emit(task_id, "导出 IPA...")
|
||||
export_plist = build_dir / "exportOptions.plist"
|
||||
export_cmd = (
|
||||
f"xcodebuild -exportArchive "
|
||||
f"-archivePath {archive_path} "
|
||||
f"-exportPath {export_path} "
|
||||
f"-exportOptionsPlist {export_plist}"
|
||||
)
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
export_cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("导出 IPA 失败")
|
||||
|
||||
# 查找 IPA 文件
|
||||
ipa_files = list(export_path.glob("*.ipa"))
|
||||
if not ipa_files:
|
||||
raise Exception("未找到 IPA 文件")
|
||||
|
||||
ipa_path = ipa_files[0]
|
||||
await log_streamer.emit(task_id, f"IPA 已导出: {ipa_path.name}")
|
||||
return ipa_path
|
||||
|
||||
|
||||
async def find_dsym(task_id: str, build_dir: Path) -> Path:
|
||||
"""查找 dSYM 文件"""
|
||||
derived_data = build_dir / "build" / "derived_data"
|
||||
if not derived_data.exists():
|
||||
return None
|
||||
|
||||
for dsym in derived_data.rglob("*.dSYM"):
|
||||
return dsym
|
||||
return None
|
||||
|
||||
|
||||
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> tuple:
|
||||
"""上传 IPA,返回 (download_url, qr_code_path)"""
|
||||
await log_streamer.emit_step(task_id, "上传分发平台")
|
||||
|
||||
qr_code_path = None
|
||||
app_guid = config_data.get("APPID", "")
|
||||
|
||||
# 从 config.json 读取上传配置并注入
|
||||
from ..routers.config import load_config
|
||||
full_config = load_config()
|
||||
config_data["_upload_config"] = full_config.get("upload", {})
|
||||
|
||||
# 使用现有的 upload_iap 脚本
|
||||
upload_script = AUTOPACKING_DIR / "upload_iap.py"
|
||||
if not upload_script.exists():
|
||||
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
|
||||
return None, None
|
||||
|
||||
cmd = [
|
||||
"python3",
|
||||
str(upload_script),
|
||||
"--config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
oss_url = None
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
# 解析上传结果
|
||||
if decoded.startswith("UPLOAD_RESULT:"):
|
||||
try:
|
||||
result = json.loads(decoded[len("UPLOAD_RESULT:"):])
|
||||
oss_url = result.get("download_url")
|
||||
qr_code_path = result.get("qr_code_path")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await process.wait()
|
||||
|
||||
# 兜底:查找二维码文件
|
||||
if not qr_code_path and app_guid:
|
||||
expected_qr = PROJECT_ROOT / "build" / f"{app_guid}.png"
|
||||
if expected_qr.exists():
|
||||
qr_code_path = str(expected_qr)
|
||||
|
||||
if process.returncode != 0:
|
||||
await log_streamer.emit_warning(task_id, "上传可能失败")
|
||||
|
||||
return oss_url, qr_code_path
|
||||
@@ -0,0 +1,86 @@
|
||||
"""WebSocket 日志流"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, AsyncGenerator
|
||||
|
||||
|
||||
class LogStreamer:
|
||||
"""日志流管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._queues: Dict[str, asyncio.Queue] = {}
|
||||
self._subscribers: Dict[str, list] = {}
|
||||
|
||||
def create_queue(self, task_id: str) -> asyncio.Queue:
|
||||
"""创建任务的日志队列"""
|
||||
queue = asyncio.Queue(maxsize=1000)
|
||||
self._queues[task_id] = queue
|
||||
return queue
|
||||
|
||||
async def emit(self, task_id: str, message: str, level: str = "info"):
|
||||
"""发送日志消息"""
|
||||
if task_id not in self._queues:
|
||||
return
|
||||
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"level": level,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
try:
|
||||
self._queues[task_id].put_nowait(log_entry)
|
||||
except asyncio.QueueFull:
|
||||
# 队列满了,丢弃最旧的消息
|
||||
try:
|
||||
self._queues[task_id].get_nowait()
|
||||
self._queues[task_id].put_nowait(log_entry)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
async def emit_step(self, task_id: str, step: str):
|
||||
"""发送步骤标记"""
|
||||
await self.emit(task_id, f"[{step}]", level="step")
|
||||
|
||||
async def emit_error(self, task_id: str, error: str):
|
||||
"""发送错误消息"""
|
||||
await self.emit(task_id, error, level="error")
|
||||
|
||||
async def emit_warning(self, task_id: str, warning: str):
|
||||
"""发送警告消息"""
|
||||
await self.emit(task_id, warning, level="warn")
|
||||
|
||||
async def subscribe(self, task_id: str) -> AsyncGenerator[dict, None]:
|
||||
"""订阅任务日志"""
|
||||
queue = self._queues.get(task_id)
|
||||
if not queue:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
if msg is None: # 结束信号
|
||||
break
|
||||
yield msg
|
||||
except asyncio.TimeoutError:
|
||||
# 发送心跳
|
||||
yield {"timestamp": datetime.now().isoformat(), "level": "heartbeat", "message": ""}
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def complete(self, task_id: str):
|
||||
"""标记任务日志结束"""
|
||||
if task_id in self._queues:
|
||||
try:
|
||||
self._queues[task_id].put_nowait(None)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def cleanup(self, task_id: str):
|
||||
"""清理任务日志队列"""
|
||||
self._queues.pop(task_id, None)
|
||||
|
||||
|
||||
# 全局单例
|
||||
log_streamer = LogStreamer()
|
||||
Reference in New Issue
Block a user