iOSBuildServer/backend/services/build_service.py
shen 6f4f625c56 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
2026-06-06 17:42:27 +08:00

591 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""打包服务核心"""
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