"""打包服务核心""" import asyncio import json import os import shutil from datetime import datetime from pathlib import Path from typing import List from ..config import ( PROJECT_ROOT, AUTOPACKING_DIR, BUILD_BASE_DIR, COPY_ITEMS, DEFAULT_BUILD_DIR_RETENTION_HOURS, GIT_REMOTE_URL, BUILD_TIMEOUT_HOURS, get_source_dir, ) from .log_streamer import log_streamer class BuildError(Exception): """带分类的打包错误""" def __init__(self, message: str, category: str = "unknown", detail: str = ""): super().__init__(message) self.category = category self.detail = detail # 错误分类规则:(关键词列表, category, 友好提示) _ERROR_RULES = [ (["No signing certificate", "Signing certificate \"", "Code Signing Error", "Code Sign error", "errSecInternalComponent", "CSSMERR_TP_NOT_TRUSTED", "iPhone Distribution", "iPhone Developer: no identity found"], "certificate", "签名证书问题:证书过期、未安装或不匹配,请检查钥匙串中的证书"), (["Provisioning profile", "No matching provisioning profiles", "embedded.mobileprovision", "PROVISIONING_PROFILE_SPECIFIER", "provisioning profile", "No matching", "requires a provisioning profile"], "provisioning", "描述文件问题:描述文件过期、不匹配或未安装,请检查 Apple Developer 后台"), (["Undefined symbols", "linker command failed", "ld: symbol(s) not found", "ld: framework not found for"], "compilation", "链接错误:符号未定义或框架缺失,请检查代码引用"), (["Swift Compiler Error", "Use of undeclared identifier", "cannot find type", "cannot find symbol in scope", "no such module"], "compilation", "Swift 编译错误:请检查代码语法和类型引用"), ([" error:", "fatal error:"], "compilation", "代码编译错误:请检查代码是否有语法或类型错误"), (["CocoaPods", "pod install", "ld: library not found for", "library not found for", "Sandbox is not in sync with Podfile.lock"], "dependency", "依赖问题:Pod 依赖缺失或版本不兼容,请检查 Podfile"), (["xcodebuild: error", "Unable to access scheme", "does not contain a scheme"], "build", "构建配置错误:Scheme 或 Workspace 配置有误"), ] def _classify_build_error(output_lines: list, step: str) -> tuple: """解析构建输出,返回 (category, friendly_message) Args: output_lines: 构建过程的输出行列表 step: 失败步骤 (archive/export) Returns: (category, friendly_message) 元组 """ output_text = "\n".join(output_lines) for keywords, category, hint in _ERROR_RULES: for kw in keywords: if kw.lower() in output_text.lower(): return category, hint # 未匹配到特定分类 if step == "archive": return "build", "Archive 失败:请查看详细日志排查原因" elif step == "export": return "build", "导出 IPA 失败:请查看详细日志排查原因" return "unknown", "打包失败:请查看详细日志排查原因" 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 _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(): 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) await asyncio.to_thread(_cleanup_old_logs) build_dir = None try: timeout_seconds = BUILD_TIMEOUT_HOURS * 3600 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)) # 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) # 3.5 安装依赖 await asyncio.to_thread(_db_update, db, task, current_step="pod_install") await run_pod_install(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) # 5. 构建项目 await asyncio.to_thread(_db_update, db, task, current_step="build") ipa_path = await build_project(task_id, task, config_data, 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, 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(): await asyncio.to_thread(log_streamer.save_log, task_id, build_dir) # 清理打包目录(保留产物文件,删除源码拷贝) 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()) await log_streamer.emit(task_id, "任务已取消") if build_dir and build_dir.exists(): await asyncio.to_thread(log_streamer.save_log, task_id, build_dir) await asyncio.to_thread(_cleanup_build_dir, build_dir) except Exception as e: if isinstance(e, BuildError): category = e.category error_msg = str(e) else: # 根据异常消息推断分类 msg = str(e).lower() if "git" in msg: category = "git" elif "配置替换" in msg: category = "config" elif "混淆" in msg: category = "config" else: category = "unknown" error_msg = str(e) await asyncio.to_thread(_db_update, db, task, status="failed", completed_at=datetime.utcnow(), error_message=error_msg, error_category=category) await log_streamer.emit_error(task_id, f"打包失败: {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) 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_lock 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 # App_Store 打包时自动递增 Build_Ver 第四位版本号 if task.build_type == "App_Store" and start_build_path.exists(): async with _config_lock: content = start_build_path.read_text(encoding="utf-8") new_lines = [] for line in content.split("\n"): if line.startswith("Build_Ver") and '"' in line: old_ver = line.split('"')[1] parts = old_ver.split(".") if len(parts) >= 4: parts[3] = str(int(parts[3]) + 1) else: parts.append("1") build_ver = ".".join(parts) new_lines.append(f'Build_Ver = "{build_ver}"') else: new_lines.append(line) start_build_path.write_text("\n".join(new_lines), encoding="utf-8") await log_streamer.emit(task_id, f"Build_Ver 已递增: {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", "")), "UPLOAD_KEY": app.get("upload_key", task.app_id), "SCHEME": scheme.get("name", ""), "OSS_FLODER": scheme.get("ossFloder", ""), "BUILD_TYPE": task.build_type, "ENABLE_OBFUSCATION": task.obfuscation, } # 证书配置 certificates = app.get("certificates", {}) # 无需证书的应用(如英汉大词典),使用项目默认签名 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: 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", "") for key in ["weixinlogin", "weixinpay", "tencent", "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, "替换项目配置") # 清理 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( *cmd, cwd=str(build_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) patch_lines = [] async for line in process.stdout: decoded = line.decode("utf-8", errors="replace").strip() if decoded: patch_lines.append(decoded) await log_streamer.emit(task_id, decoded) await process.wait() # 保存替换日志 patch_log_path = build_dir / "patch_result.log" with open(patch_log_path, "w", encoding="utf-8") as f: f.write("\n".join(patch_lines)) if process.returncode != 0: raise Exception("项目配置替换失败") 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, "代码混淆") env = os.environ.copy() env["TARGET_NAME"] = config_data.get("SCHEME", "") 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, config_data: dict, build_dir: Path) -> Path: """构建项目""" await log_streamer.emit_step(task_id, "构建项目") scheme = config_data.get("SCHEME", "") 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" ) archive_output = [] 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: archive_output.append(decoded) await log_streamer.emit(task_id, decoded) await process.wait() if process.returncode != 0: category, hint = _classify_build_error(archive_output, "archive") raise BuildError(hint, category=category, detail="\n".join(archive_output[-20:])) # 导出 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}" ) export_output = [] 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: export_output.append(decoded) await log_streamer.emit(task_id, decoded) await process.wait() if process.returncode != 0: category, hint = _classify_build_error(export_output, "export") raise BuildError(hint, category=category, detail="\n".join(export_output[-20:])) # 查找 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, build_dir: 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.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-file", str(upload_config_file), ] process = await asyncio.create_subprocess_exec( *cmd, cwd=str(build_dir), 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: raise BuildError("上传分发平台失败", category="upload") return oss_url, qr_code_path