diff --git a/.env.example b/.env.example index 1255727..50a93fa 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ GIT_REMOTE_URL=git@github.com:your-org/readoor.git # ---- 服务端口 ---- BACKEND_PORT=8000 +FRONTEND_PORT=3000 # ---- 管理员账号 ---- ADMIN_USERNAME=admin @@ -29,6 +30,9 @@ MAX_CONCURRENT_BUILDS=2 # 打包目录保留时间(小时) BUILD_DIR_RETENTION_HOURS=24 +# 打包超时时间(小时),超时自动标记失败 +BUILD_TIMEOUT_HOURS=1 + # ---- Watchdog 健康监测 ---- # 健康检查间隔(秒) WATCHDOG_INTERVAL=30 diff --git a/backend/config.py b/backend/config.py index 3a5456b..dd1d5dc 100644 --- a/backend/config.py +++ b/backend/config.py @@ -49,6 +49,7 @@ COPY_ITEMS = [ # 服务端口 BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000")) +FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "3000")) # 数据库路径 DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}" @@ -62,6 +63,9 @@ DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2")) # 默认打包目录保留时间(小时) DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24")) +# 打包超时时间(小时) +BUILD_TIMEOUT_HOURS = int(os.getenv("BUILD_TIMEOUT_HOURS", "1")) + # 管理员账号配置 ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") diff --git a/backend/routers/config.py b/backend/routers/config.py index 06c4184..526e28b 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -64,6 +64,17 @@ DEFAULT_SERVERS = { } +def _ensure_upload_keys(config: dict) -> bool: + """为没有 upload_key 的 app 自动生成,返回是否有变更""" + import uuid + changed = False + for app_id, app in config.get("apps", {}).items(): + if not app.get("upload_key"): + app["upload_key"] = uuid.uuid4().hex[:8] + changed = True + return changed + + def load_config() -> dict: """读取 config.json""" if not CONFIG_JSON_PATH.exists(): @@ -77,6 +88,9 @@ def load_config() -> dict: config["branches"] = ["main"] if "upload" not in config: config["upload"] = DEFAULT_UPLOAD + # 自动为缺少 upload_key 的 app 生成唯一标识 + if _ensure_upload_keys(config): + save_config(config) return config @@ -119,6 +133,7 @@ async def get_apps(): @router.post("/apps") async def create_app(app: dict): """新增 app""" + import uuid async with _config_lock: config = load_config() apps = config.get("apps", {}) @@ -127,6 +142,10 @@ async def create_app(app: dict): numeric_keys = [int(k) for k in apps.keys() if k.isdigit()] new_id = str(max(numeric_keys) + 1) if numeric_keys else "1" + # 自动生成 upload_key + if not app.get("upload_key"): + app["upload_key"] = uuid.uuid4().hex[:8] + apps[new_id] = app config["apps"] = apps save_config(config) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 2e948ee..0d44bc0 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -57,6 +57,30 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)): return db_task +def _enrich_tasks(tasks): + """为任务列表补充 has_log 字段""" + from pathlib import Path + logs_dir = Path(__file__).parent.parent / "logs" + result = [] + for task in tasks: + resp = TaskResponse.from_orm(task) + # 检查独立日志目录 + if logs_dir.exists() and (logs_dir / f"{task.id}.log").exists(): + resp.has_log = True + # 检查构建目录 + elif task.build_dir: + log_path = Path(task.build_dir) / "build.log" + if log_path.exists(): + resp.has_log = True + # 运行中的任务,内存中有日志 + elif task.status == "running": + from ..services.log_streamer import log_streamer + if task.id in log_streamer._log_lines: + resp.has_log = True + result.append(resp) + return result + + @router.get("", response_model=List[TaskResponse]) async def list_tasks( status: str = None, @@ -69,7 +93,7 @@ async def list_tasks( if status: query = query.filter(Task.status == status) tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() - return tasks + return _enrich_tasks(tasks) @router.get("/{task_id}", response_model=TaskResponse) @@ -78,7 +102,7 @@ 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 + return _enrich_tasks([task])[0] @router.delete("/{task_id}") @@ -98,9 +122,50 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)): from ..services.build_queue import build_queue build_queue.cancel(task_id) + # 清理残留文件 + import shutil + from pathlib import Path + log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" + if log_path.exists(): + log_path.unlink() + if task.build_dir: + build_path = Path(task.build_dir) + if build_path.exists(): + shutil.rmtree(build_path, ignore_errors=True) + return {"message": "任务已取消"} +@router.delete("/{task_id}/delete") +async def delete_task(task_id: str, db: Session = Depends(get_db)): + """删除打包记录""" + import shutil + from pathlib import Path + + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status in ("running", "pending"): + raise HTTPException(status_code=400, detail="任务正在运行中,无法删除") + + # 删除日志文件 + log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" + if log_path.exists(): + log_path.unlink() + + # 删除构建目录 + if task.build_dir: + build_path = Path(task.build_dir) + if build_path.exists(): + shutil.rmtree(build_path, ignore_errors=True) + + db.delete(task) + db.commit() + + return {"message": "记录已删除"} + + @router.get("/{task_id}/log") async def get_build_log(task_id: str, db: Session = Depends(get_db)): """获取打包日志""" @@ -121,9 +186,14 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)): content += f"[{ts}] [{level}] {msg}\n" return {"log": content} - # 从文件获取(任务已完成) + # 从独立日志目录获取 + from pathlib import Path + log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log" + if log_path.exists(): + return {"log": log_path.read_text(encoding="utf-8")} + + # 从构建目录获取(兼容旧数据) if task.build_dir: - from pathlib import Path log_path = Path(task.build_dir) / "build.log" if log_path.exists(): return {"log": log_path.read_text(encoding="utf-8")} @@ -134,16 +204,32 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)): @router.get("/{task_id}/dsym") async def download_dsym(task_id: str, db: Session = Depends(get_db)): """下载 dSYM 文件""" + import shutil + import zipfile + import tempfile + 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): + dsym_path = task.dsym_path + if not os.path.exists(dsym_path): raise HTTPException(status_code=404, detail="dSYM 文件已被清理") - return FileResponse(task.dsym_path, filename=os.path.basename(task.dsym_path)) + # .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载 + if os.path.isdir(dsym_path): + zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip") + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(dsym_path): + for f in files: + full = os.path.join(root, f) + arcname = os.path.relpath(full, os.path.dirname(dsym_path)) + zf.write(full, arcname) + return FileResponse(zip_path, filename=os.path.basename(zip_path), media_type="application/zip") + + return FileResponse(dsym_path, filename=os.path.basename(dsym_path)) @router.get("/{task_id}/obfuscation-maps") diff --git a/backend/schemas.py b/backend/schemas.py index 3715035..be8dcc0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -36,6 +36,7 @@ class TaskResponse(BaseModel): error_message: Optional[str] error_category: Optional[str] = None config_json: Optional[str] + has_log: bool = False class Config: from_attributes = True diff --git a/backend/services/build_service.py b/backend/services/build_service.py index e9f9a4e..e7772db 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -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 diff --git a/backend/services/log_streamer.py b/backend/services/log_streamer.py index 9540c68..ca8a2e3 100644 --- a/backend/services/log_streamer.py +++ b/backend/services/log_streamer.py @@ -64,6 +64,12 @@ class LogStreamer: if not queue: return + # 回放已缓存的历史日志 + existing = self._log_lines.get(task_id, []) + for entry in existing: + yield entry + + # 流式推送新消息 while True: try: msg = await asyncio.wait_for(queue.get(), timeout=30) @@ -89,12 +95,15 @@ class LogStreamer: self._queues.pop(task_id, None) def save_log(self, task_id: str, build_dir: Path): - """将收集的日志保存到打包目录""" - lines = self._log_lines.pop(task_id, None) + """将收集的日志保存到独立日志目录(不会随构建目录删除)""" + lines = self._log_lines.get(task_id) if not lines: return - log_path = build_dir / "build.log" + # 保存到 backend/logs/ 目录 + logs_dir = Path(__file__).parent.parent / "logs" + logs_dir.mkdir(exist_ok=True) + log_path = logs_dir / f"{task_id}.log" with open(log_path, "w", encoding="utf-8") as f: for entry in lines: ts = entry["timestamp"] @@ -102,11 +111,28 @@ class LogStreamer: msg = entry["message"] f.write(f"[{ts}] [{level}] {msg}\n") - def get_log(self, build_dir: Path) -> str: - """读取已保存的打包日志""" - log_path = build_dir / "build.log" - if log_path.exists(): - return log_path.read_text(encoding="utf-8") + # 同时保存到构建目录(如果存在) + if build_dir and build_dir.exists(): + with open(build_dir / "build.log", "w", encoding="utf-8") as f: + for entry in lines: + ts = entry["timestamp"] + level = entry["level"].upper() + msg = entry["message"] + f.write(f"[{ts}] [{level}] {msg}\n") + + def get_log(self, build_dir: Path, task_id: str = None) -> str: + """读取已保存的打包日志(优先从独立日志目录读取)""" + # 优先从独立日志目录读取 + if task_id: + logs_dir = Path(__file__).parent.parent / "logs" + log_path = logs_dir / f"{task_id}.log" + if log_path.exists(): + return log_path.read_text(encoding="utf-8") + # 回退到构建目录 + if build_dir: + log_path = build_dir / "build.log" + if log_path.exists(): + return log_path.read_text(encoding="utf-8") return "" diff --git a/frontend/src/views/BuildView.vue b/frontend/src/views/BuildView.vue index 5a7d4d2..c9f2172 100644 --- a/frontend/src/views/BuildView.vue +++ b/frontend/src/views/BuildView.vue @@ -352,7 +352,8 @@ const refreshTasks = async () => { const formatTime = (t) => { if (!t) return '-' - const d = new Date(t) + // 日志时间戳是本地时间,任务时间字段是 UTC + const d = t.length > 15 && !t.endsWith('Z') && !t.includes('+') ? new Date(t + 'Z') : new Date(t) return d.toLocaleTimeString() } diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue index 3f0e2fc..85b783e 100644 --- a/frontend/src/views/HistoryView.vue +++ b/frontend/src/views/HistoryView.vue @@ -27,12 +27,18 @@