feat: 构建超时控制、日志持久化、任务删除、pod install 及多项 UI 优化

- 新增 BUILD_TIMEOUT_HOURS 配置,超时自动标记失败
- 日志保存到独立 backend/logs/ 目录,不随构建目录删除
- 新增任务删除 API 及前端删除按钮
- 构建流程增加 pod install 步骤
- 通过文件传递配置避免命令行参数截断
- Provisioning Profile 支持按名称搜索
- dSYM 下载支持目录打包为 zip
- 前端端口可配置、历史页增加下载列和耗时显示
- 日志弹窗支持展开/收起详细日志
- 自动生成 upload_key 标识
This commit is contained in:
shen
2026-06-08 18:56:43 +08:00
parent 75363ee276
commit f5919a8229
11 changed files with 535 additions and 212 deletions
+209 -100
View File
@@ -14,6 +14,7 @@ from ..config import (
COPY_ITEMS,
DEFAULT_BUILD_DIR_RETENTION_HOURS,
GIT_REMOTE_URL,
BUILD_TIMEOUT_HOURS,
get_source_dir,
)
from .log_streamer import log_streamer
@@ -108,6 +109,21 @@ def _cleanup_old_builds(db):
pass
def _cleanup_old_logs():
"""清理超过 7 天的打包日志"""
logs_dir = Path(__file__).parent.parent / "logs"
if not logs_dir.exists():
return
cutoff = datetime.utcnow().timestamp() - 7 * 86400
for f in logs_dir.iterdir():
if f.is_file() and f.suffix == ".log":
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception:
pass
def _db_update(db, task, **fields):
"""同步更新 task 字段并 commit(在线程中调用)"""
for k, v in fields.items():
@@ -209,80 +225,92 @@ async def run_build_task(task_id: str):
await log_streamer.emit_step(task_id, "开始打包")
# 清理过期的打包目录
# 清理过期的打包目录和日志
await asyncio.to_thread(_cleanup_old_builds, db)
await asyncio.to_thread(_cleanup_old_logs)
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))
timeout_seconds = BUILD_TIMEOUT_HOURS * 3600
# 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))
async def _do_build():
nonlocal build_dir
# 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))
# 3. 替换项目配置
await asyncio.to_thread(_db_update, db, task, current_step="patch")
await patch_project(task_id, task, config_data, 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))
# 4. 代码混淆(可选)
if task.obfuscation:
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
await run_obfuscation(task_id, task, config_data, build_dir, source_dir)
# 3. 替换项目配置
await asyncio.to_thread(_db_update, db, task, current_step="patch")
await patch_project(task_id, task, config_data, build_dir)
# 5. 构建项目
await asyncio.to_thread(_db_update, db, task, current_step="build")
ipa_path = await build_project(task_id, task, config_data, build_dir)
# 3.5 安装依赖
await asyncio.to_thread(_db_update, db, task, current_step="pod_install")
await run_pod_install(task_id, build_dir)
# 查找 dSYM
dsym_path = await find_dsym(task_id, build_dir)
# 4. 代码混淆(可选)
if task.obfuscation:
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
await run_obfuscation(task_id, task, config_data, build_dir, source_dir)
# 查找混淆映射表
obf_maps_path = build_dir / "obfuscation_maps"
# 5. 构建项目
await asyncio.to_thread(_db_update, db, task, current_step="build")
ipa_path = await build_project(task_id, task, config_data, build_dir)
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)
# 查找 dSYM
dsym_path = await find_dsym(task_id, build_dir)
# 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)
# 查找混淆映射表
obf_maps_path = build_dir / "obfuscation_maps"
# 完成
await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(),
current_step=None)
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)
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}")
# 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, build_dir)
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 asyncio.wait_for(_do_build(), timeout=timeout_seconds)
# 保存打包日志
if build_dir and build_dir.exists():
@@ -292,6 +320,16 @@ async def run_build_task(task_id: str):
await log_streamer.emit(task_id, "清理临时文件...")
await asyncio.to_thread(_cleanup_build_dir, build_dir)
except asyncio.TimeoutError:
error_msg = f"打包超时(已运行 {BUILD_TIMEOUT_HOURS} 小时)"
await asyncio.to_thread(_db_update, db, task,
status="failed", completed_at=datetime.utcnow(),
error_message=error_msg, error_category="timeout")
await log_streamer.emit_error(task_id, error_msg)
if build_dir and build_dir.exists():
await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}")
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
except asyncio.CancelledError:
await asyncio.to_thread(_db_update, db, task,
status="cancelled", completed_at=datetime.utcnow())
@@ -408,6 +446,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
"API": app.get("API", ""),
"APPID_NAME": app.get("name", ""),
"APPID": app.get("AppGuid", app.get("AppId", "")),
"UPLOAD_KEY": app.get("upload_key", task.app_id),
"SCHEME": scheme.get("name", ""),
"OSS_FLODER": scheme.get("ossFloder", ""),
"BUILD_TYPE": task.build_type,
@@ -416,30 +455,59 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
# 证书配置
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", "")
# 无需证书的应用(如英汉大词典),使用项目默认签名
if not certificates:
config_data["NO_CERT"] = True
await log_streamer.emit(task_id, "该应用无需配置证书,使用项目默认签名")
else:
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", "")
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
if provisioning_profile and os.path.exists(provisioning_profile):
try:
import subprocess as _sp
plist_xml = _sp.check_output(
["security", "cms", "-D", "-i", provisioning_profile],
stderr=_sp.DEVNULL,
)
import plistlib as _pl
plist = _pl.loads(plist_xml)
team_ids = plist.get("TeamIdentifier", [])
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
except Exception:
pass
if provisioning_profile:
profile_path = provisioning_profile
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
if not os.path.exists(profile_path):
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
if os.path.isdir(profiles_dir):
import subprocess as _sp
import plistlib as _pl
for filename in os.listdir(profiles_dir):
if not filename.endswith(".mobileprovision"):
continue
candidate = os.path.join(profiles_dir, filename)
try:
plist_xml = _sp.check_output(
["security", "cms", "-D", "-i", candidate],
stderr=_sp.DEVNULL,
)
plist = _pl.loads(plist_xml)
if plist.get("Name") == provisioning_profile:
profile_path = candidate
break
except Exception:
continue
if os.path.exists(profile_path):
try:
import subprocess as _sp
import plistlib as _pl
plist_xml = _sp.check_output(
["security", "cms", "-D", "-i", profile_path],
stderr=_sp.DEVNULL,
)
plist = _pl.loads(plist_xml)
team_ids = plist.get("TeamIdentifier", [])
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
except Exception:
pass
# 关联域名等配置
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
@@ -460,21 +528,28 @@ 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),
]
# 清理 RDAppConfiguration.swift 中的注释配置行,避免 replace_line_by_keyword 替换到注释行
swift_config = build_dir / "readoor" / "3.0" / "AppConfig" / "RDAppConfiguration.swift"
if swift_config.exists():
for keyword in [r"\/\/let RD_APP_GUID:", r"\/\/let RD_API_DOMAIN:"]:
sed_proc = await asyncio.create_subprocess_exec(
"sed", "-i", "", f"/^[[:space:]]*{keyword}/d", str(swift_config),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
await sed_proc.wait()
await log_streamer.emit(task_id, "已清理注释配置行")
# 通过 config_output.json 文件传递配置(避免命令行参数过长截断)
config_file = build_dir / "config_output.json"
mode_flag = "--dict-config-file" if config_data.get("SCHEME") == "readoorDict" else "--config-file"
cmd = [
"python3",
str(AUTOPACKING_DIR / "replace_build_info.py"),
mode_flag,
str(config_file),
]
# 使用 subprocess 执行替换
process = await asyncio.create_subprocess_exec(
@@ -504,6 +579,30 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
await log_streamer.emit(task_id, "项目配置替换完成")
async def run_pod_install(task_id: str, build_dir: Path):
"""安装 CocoaPods 依赖"""
await log_streamer.emit_step(task_id, "安装依赖")
process = await asyncio.create_subprocess_exec(
"pod", "install",
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 BuildError("pod install 失败", category="config")
await log_streamer.emit(task_id, "依赖安装完成")
async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path, source_root: Path):
"""执行代码混淆"""
await log_streamer.emit_step(task_id, "代码混淆")
@@ -664,7 +763,7 @@ async def find_dsym(task_id: str, build_dir: Path) -> Path:
return None
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> tuple:
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
"""上传 IPA,返回 (download_url, qr_code_path)"""
await log_streamer.emit_step(task_id, "上传分发平台")
@@ -676,22 +775,32 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> t
full_config = load_config()
config_data["_upload_config"] = full_config.get("upload", {})
# 注入路径信息,供 upload_iap.py 使用绝对路径
config_data["IPA_PATH"] = str(ipa_path)
config_data["BUILD_DIR"] = str(build_dir)
config_data["AUTOPACKING_DIR"] = str(AUTOPACKING_DIR)
# 使用现有的 upload_iap 脚本
upload_script = AUTOPACKING_DIR / "upload_iap.py"
if not upload_script.exists():
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
return None, None
# 通过临时文件传递配置
upload_config_file = build_dir / "upload_config.json"
with open(upload_config_file, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2, ensure_ascii=False)
cmd = [
"python3",
str(upload_script),
"--config",
json.dumps(config_data, ensure_ascii=False),
"--config-file",
str(upload_config_file),
]
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(PROJECT_ROOT),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -719,6 +828,6 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> t
qr_code_path = str(expected_qr)
if process.returncode != 0:
await log_streamer.emit_warning(task_id, "上传可能失败")
raise BuildError("上传分发平台失败", category="upload")
return oss_url, qr_code_path