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 @@ 打包类型 Scheme 状态 + 下载地址 操作 - {{ formatTime(task.created_at) }} + + {{ formatTime(task.created_at) }} + + {{ formatDuration(task.started_at, task.completed_at) }} + + {{ task.app_name }} @@ -46,16 +52,24 @@ {{ errorCategoryLabel(task.error_category) }} + + + - + - - + + - - + - 暂无打包记录 + 暂无打包记录 @@ -97,15 +111,23 @@ 已启用 +
+ +
-
- - -
+
暂无日志 @@ -118,28 +140,6 @@
- - - @@ -149,12 +149,11 @@ import { ref, computed, onMounted, nextTick, onUnmounted } from 'vue' const tasks = ref([]) const filterBuildType = ref('') const filterStatus = ref('') -const showQrModal = ref(false) -const qrTask = ref(null) const showLogModal = ref(false) const logTask = ref(null) const logLines = ref([]) const logContainer = ref(null) +const showVerboseLogs = ref(false) let logWs = null const filteredTasks = computed(() => { @@ -177,35 +176,55 @@ const viewLogs = async (taskId) => { if (!task) return logTask.value = task logLines.value = [] + showVerboseLogs.value = false showLogModal.value = true if (task.status === 'running' || task.status === 'pending') { // 进行中的任务,连接 WebSocket 获取实时日志 connectLogWs(taskId) } else { - // 已完成/失败的任务,显示已有信息 - if (task.error_message) { - logLines.value.push({ level: 'error', message: `错误: ${task.error_message}` }) - } - if (task.error_category) { - logLines.value.push({ level: 'warn', message: `分类: ${errorCategoryLabel(task.error_category)}` }) - } - if (task.config_json) { - try { - const cfg = JSON.parse(task.config_json) - logLines.value.push({ level: 'info', message: `版本: ${cfg.VERSION} Build: ${cfg.BUILD_VERSION}` }) - logLines.value.push({ level: 'info', message: `Scheme: ${cfg.SCHEME} 类型: ${cfg.BUILD_TYPE}` }) - if (cfg.CERTIFICATE) logLines.value.push({ level: 'info', message: `证书: ${cfg.CERTIFICATE}` }) - if (cfg.BUNDLE_ID) logLines.value.push({ level: 'info', message: `BundleID: ${cfg.BUNDLE_ID}` }) - } catch {} - } - if (task.status === 'completed') { - logLines.value.push({ level: 'step', message: '[打包完成]' }) - if (task.oss_url) logLines.value.push({ level: 'info', message: `下载链接: ${task.oss_url}` }) - } else if (task.status === 'failed') { - logLines.value.push({ level: 'step', message: '[打包失败]' }) - } else if (task.status === 'cancelled') { - logLines.value.push({ level: 'info', message: '任务已取消' }) + // 已完成/失败的任务,先从 REST API 获取完整日志 + try { + const res = await fetch(`/api/tasks/${taskId}/log`) + if (res.ok) { + const data = await res.json() + if (data.log) { + const lines = data.log.split('\n').filter(Boolean) + logLines.value = lines.map(line => { + const m = line.match(/^\[(.+?)\]\s+\[(.+?)\]\s+(.*)$/) + if (m) { + return { timestamp: m[1], level: m[2].toLowerCase(), message: m[3] } + } + return { timestamp: '', level: 'info', message: line } + }) + } + } + } catch {} + // 日志为空时,回退到元数据展示 + if (!logLines.value.length) { + if (task.error_message) { + logLines.value.push({ level: 'error', message: `错误: ${task.error_message}` }) + } + if (task.error_category) { + logLines.value.push({ level: 'warn', message: `分类: ${errorCategoryLabel(task.error_category)}` }) + } + if (task.config_json) { + try { + const cfg = JSON.parse(task.config_json) + logLines.value.push({ level: 'info', message: `版本: ${cfg.VERSION} Build: ${cfg.BUILD_VERSION}` }) + logLines.value.push({ level: 'info', message: `Scheme: ${cfg.SCHEME} 类型: ${cfg.BUILD_TYPE}` }) + if (cfg.CERTIFICATE) logLines.value.push({ level: 'info', message: `证书: ${cfg.CERTIFICATE}` }) + if (cfg.BUNDLE_ID) logLines.value.push({ level: 'info', message: `BundleID: ${cfg.BUNDLE_ID}` }) + } catch {} + } + if (task.status === 'completed') { + logLines.value.push({ level: 'step', message: '[打包完成]' }) + if (task.oss_url) logLines.value.push({ level: 'info', message: `下载链接: ${task.oss_url}` }) + } else if (task.status === 'failed') { + logLines.value.push({ level: 'step', message: '[打包失败]' }) + } else if (task.status === 'cancelled') { + logLines.value.push({ level: 'info', message: '任务已取消' }) + } } } } @@ -246,27 +265,33 @@ const downloadObfMaps = (taskId) => { window.open(`/api/tasks/${taskId}/obfuscation-maps`) } -const downloadIpa = (taskId) => { - window.open(`/api/tasks/${taskId}/ipa`) -} - -const showQrCode = (task) => { - qrTask.value = task - showQrModal.value = true -} - -const downloadQrCode = () => { - if (qrTask.value) { - const link = document.createElement('a') - link.href = `/api/tasks/${qrTask.value.id}/qrcode` - link.download = `${qrTask.value.app_name}_二维码.png` - link.click() - } +const deleteTask = async (taskId) => { + if (!confirm('确定要删除这条打包记录吗?')) return + try { + const res = await fetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' }) + if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId) + } catch {} } const formatTime = (t) => { if (!t) return '-' - return new Date(t).toLocaleString() + const d = t.endsWith('Z') || t.includes('+') ? new Date(t) : new Date(t + 'Z') + return d.toLocaleString() +} + +const formatDuration = (started, completed) => { + if (!started || !completed) return '' + const start = started.endsWith('Z') || started.includes('+') ? new Date(started) : new Date(started + 'Z') + const end = completed.endsWith('Z') || completed.includes('+') ? new Date(completed) : new Date(completed + 'Z') + const seconds = Math.floor((end - start) / 1000) + if (seconds < 0) return '' + if (seconds < 60) return `${seconds}秒` + const minutes = Math.floor(seconds / 60) + const remainSeconds = seconds % 60 + if (minutes < 60) return `${minutes}分${remainSeconds}秒` + const hours = Math.floor(minutes / 60) + const remainMinutes = minutes % 60 + return `${hours}时${remainMinutes}分` } const statusText = (s) => { @@ -283,6 +308,8 @@ const errorCategoryLabel = (cat) => { git: '代码拉取', config: '配置问题', build: '构建配置', + timeout: '打包超时', + upload: '上传失败', unknown: '未知错误', } return map[cat] || '未知错误' @@ -298,17 +325,26 @@ const errorCategoryLabel = (cat) => { .filter-select { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; min-width: 120px; } .filter-select:focus { outline: none; border-color: #1890ff; } -.config-table { width: 100%; border-collapse: collapse; } -.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; } +.config-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; } .config-table td { font-size: 14px; } +.config-table th:nth-child(1) { width: 16%; } +.config-table th:nth-child(2) { width: 18%; } +.config-table th:nth-child(3) { width: 10%; } +.config-table th:nth-child(4) { width: 12%; } +.config-table th:nth-child(5) { width: 12%; } +.config-table th:nth-child(6) { width: 18%; } +.config-table th:nth-child(7) { width: 14%; } .config-table tr:hover { background: #fafafa; } .build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } .badge-adhoc { background: #e6f7ff; color: #1890ff; } .badge-appstore { background: #f6ffed; color: #52c41a; } -.action-btns { display: flex; gap: 8px; flex-wrap: wrap; } +.action-btns { white-space: normal; } +.action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; } +.download-cell { white-space: normal; } .action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; } .action-btn:hover { border-color: #1890ff; color: #1890ff; } @@ -363,6 +399,22 @@ const errorCategoryLabel = (cat) => { .meta-label { font-size: 12px; color: #999; } .meta-value { font-size: 13px; color: #333; font-weight: 500; } +.duration-text { + display: block; font-size: 11px; color: #8b949e; margin-top: 2px; +} + +/* 日志操作栏 */ +.log-modal-actions { + padding: 8px 20px; background: #161b22; border-bottom: 1px solid #30363d; + display: flex; justify-content: flex-end; +} +.btn-toggle-logs { + background: transparent; border: 1px solid #30363d; color: #58a6ff; + padding: 4px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; + transition: all 0.2s; +} +.btn-toggle-logs:hover { background: #1f2937; border-color: #58a6ff; } + /* 日志内容区 */ .log-modal-body { flex: 1; min-height: 0; max-height: 56vh; overflow-y: auto; @@ -399,16 +451,11 @@ const errorCategoryLabel = (cat) => { transition: all 0.2s; } .btn-close-log:hover { border-color: #1890ff; color: #1890ff; } -.qr-modal { background: white; border-radius: 12px; padding: 24px; width: 400px; } -.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } -.modal-header h3 { font-size: 16px; margin: 0; } -.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; } -.qr-content { text-align: center; } -.qr-image { width: 200px; height: 200px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 16px; } -.qr-info { text-align: left; padding: 16px; background: #fafafa; border-radius: 8px; margin-bottom: 16px; } -.qr-info p { margin: 8px 0; font-size: 14px; color: #333; } -.qr-actions { display: flex; justify-content: center; } -.btn { padding: 10px 24px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; } -.btn-primary { background: #1890ff; color: white; } -.btn-primary:hover { background: #40a9ff; } +.download-link { color: #1890ff; text-decoration: none; font-size: 13px; } +.download-link:hover { text-decoration: underline; } +.text-muted { color: #999; font-size: 13px; } +.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; } +.qr-thumb:hover { border-color: #1890ff; } +.btn-danger { color: #ff4d4f; border-color: #ff4d4f; } +.btn-danger:hover { background: #ff4d4f; color: white; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a6bbac6..301d03e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,14 +1,39 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import { readFileSync, existsSync } from 'fs' +import { resolve } from 'path' + +// 从项目根目录的 .env 读取端口配置 +function loadEnv() { + const env = {} + const envPath = resolve(__dirname, '../.env') + if (existsSync(envPath)) { + const content = readFileSync(envPath, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) { + const idx = trimmed.indexOf('=') + const key = trimmed.slice(0, idx).trim() + const value = trimmed.slice(idx + 1).trim() + env[key] = value + } + } + } + return env +} + +const env = loadEnv() +const backendPort = env.BACKEND_PORT || '8000' +const frontendPort = env.FRONTEND_PORT || '3000' export default defineConfig({ plugins: [vue()], server: { - port: 3000, + port: parseInt(frontendPort), proxy: { - '/api': 'http://localhost:8000', + '/api': `http://localhost:${backendPort}`, '/ws': { - target: 'ws://localhost:8000', + target: `ws://localhost:${backendPort}`, ws: true, }, }, diff --git a/start.sh b/start.sh index eaf74a6..2cfe17a 100755 --- a/start.sh +++ b/start.sh @@ -11,6 +11,7 @@ if [ -f .env ]; then fi PORT="${BACKEND_PORT:-8000}" +FRONTEND_PORT="${FRONTEND_PORT:-3000}" # 确保 .env 存在 if [ ! -f .env ]; then @@ -33,7 +34,7 @@ if [ ! -d "frontend/node_modules" ]; then fi echo "=== iOS 自动打包服务(开发模式)===" -echo " 前端: http://localhost:3000" +echo " 前端: http://localhost:$FRONTEND_PORT" echo " 后端: http://localhost:$PORT" echo " 按 Ctrl+C 停止所有服务" echo ""