From 75363ee276b96e61cb6ad11f650ebd8340d2f4bd Mon Sep 17 00:00:00 2001 From: shen <> Date: Mon, 8 Jun 2026 11:23:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=88=E6=9C=AC=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E6=9E=84=E5=BB=BA=E6=97=A5=E5=BF=97=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E3=80=81App=5FStore=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=8F=B7=E8=87=AA=E5=8A=A8=E9=80=92=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增版本号配置 API(GET/PUT /api/config/versions)和前端管理界面 - 构建日志收集与保存:任务完成/取消/失败时写入 build.log - 新增任务日志查询接口 GET /api/tasks/{task_id}/log - App_Store 打包时自动递增 Build_Ver 第四位版本号 - 从 Provisioning Profile 自动提取 TEAM_ID 和 PROVISIONING_NAME - 前端完成后支持查看完整构建日志 --- backend/routers/config.py | 51 ++++++++++++++++++++++ backend/routers/tasks.py | 30 +++++++++++++ backend/services/build_service.py | 72 ++++++++++++++++++++++++++----- backend/services/log_streamer.py | 34 +++++++++++++-- frontend/src/views/BuildView.vue | 32 +++++++++++++- frontend/src/views/ConfigView.vue | 39 ++++++++++++++++- 6 files changed, 243 insertions(+), 15 deletions(-) diff --git a/backend/routers/config.py b/backend/routers/config.py index e0d6510..06c4184 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -298,6 +298,57 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen return {"message": "打包设置已更新"} +# 版本号配置 API +@router.get("/versions") +async def get_versions(): + """获取 App_Ver 和 Build_Ver""" + from ..config import AUTOPACKING_DIR + + 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(encoding="utf-8") + for line in content.split("\n"): + if line.startswith("App_Ver") and '"' in line: + app_ver = line.split('"')[1] + elif line.startswith("Build_Ver") and '"' in line: + build_ver = line.split('"')[1] + + return {"app_ver": app_ver, "build_ver": build_ver} + + +@router.put("/versions") +async def update_versions(data: dict): + """更新 App_Ver 和 Build_Ver""" + from ..config import AUTOPACKING_DIR + + app_ver = data.get("app_ver", "").strip() + build_ver = data.get("build_ver", "").strip() + + if not app_ver or not build_ver: + raise HTTPException(status_code=400, detail="版本号不能为空") + + start_build_path = AUTOPACKING_DIR / "start_build_app.py" + if not start_build_path.exists(): + raise HTTPException(status_code=404, detail="start_build_app.py 不存在") + + async with _config_lock: + content = start_build_path.read_text(encoding="utf-8") + new_lines = [] + for line in content.split("\n"): + if line.startswith("App_Ver") and '"' in line: + new_lines.append(f'App_Ver = "{app_ver}"') + elif line.startswith("Build_Ver") and '"' in line: + 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") + + return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver} + + # 上传配置 API @router.get("/upload") async def get_upload_config(): diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 4eec234..2e948ee 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -101,6 +101,36 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)): return {"message": "任务已取消"} +@router.get("/{task_id}/log") +async def get_build_log(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="任务不存在") + + from ..services.log_streamer import log_streamer + + # 先从内存中获取(任务运行中) + lines = log_streamer._log_lines.get(task_id) + if lines: + content = "" + for entry in lines: + ts = entry["timestamp"] + level = entry["level"].upper() + msg = entry["message"] + content += f"[{ts}] [{level}] {msg}\n" + return {"log": content} + + # 从文件获取(任务已完成) + 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")} + + return {"log": ""} + + @router.get("/{task_id}/dsym") async def download_dsym(task_id: str, db: Session = Depends(get_db)): """下载 dSYM 文件""" diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 94c1435..e9f9a4e 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -5,6 +5,7 @@ import os import shutil from datetime import datetime from pathlib import Path +from typing import List from ..config import ( PROJECT_ROOT, @@ -242,11 +243,11 @@ async def run_build_task(task_id: str): # 4. 代码混淆(可选) if task.obfuscation: await asyncio.to_thread(_db_update, db, task, current_step="obfuscation") - await run_obfuscation(task_id, task, build_dir, source_dir) + 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, build_dir) + ipa_path = await build_project(task_id, task, config_data, build_dir) # 查找 dSYM dsym_path = await find_dsym(task_id, build_dir) @@ -283,6 +284,10 @@ async def run_build_task(task_id: str): if task.oss_url: await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") + # 保存打包日志 + 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) @@ -292,6 +297,7 @@ async def run_build_task(task_id: str): 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: @@ -315,7 +321,8 @@ async def run_build_task(task_id: str): 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 asyncio.to_thread(_cleanup_build_dir, build_dir) + 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) @@ -351,7 +358,7 @@ 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 + from ..routers.config import load_config, _config_lock config = load_config() apps = config.get("apps", {}) @@ -373,6 +380,26 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict: 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, @@ -397,8 +424,26 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict: config_data["BUNDLE_ID"] = cert.get("name", "") config_data["THEME"] = cert.get("theme", "") - # 其他配置 - for key in ["weixinlogin", "weixinpay", "tencent", "AssDom", "UniversalLink", "AlivcLicenseKey"]: + # 从 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 + + # 关联域名等配置 + config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "") + for key in ["weixinlogin", "weixinpay", "tencent", "UniversalLink", "AlivcLicenseKey"]: if key in app: config_data[key] = app[key] @@ -439,25 +484,32 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path): 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_obfuscation(task_id: str, task, build_dir: Path, source_root: Path): +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"] = task.scheme_name + env["TARGET_NAME"] = config_data.get("SCHEME", "") env["SKIP_OBF_PHASE"] = "1" # 自检 @@ -505,11 +557,11 @@ async def run_obfuscation(task_id: str, task, build_dir: Path, source_root: Path await log_streamer.emit(task_id, "代码混淆完成") -async def build_project(task_id: str, task, build_dir: Path) -> Path: +async def build_project(task_id: str, task, config_data: dict, build_dir: Path) -> Path: """构建项目""" await log_streamer.emit_step(task_id, "构建项目") - scheme = task.scheme_name + scheme = config_data.get("SCHEME", "") export_path = build_dir / "build" archive_path = export_path / f"{scheme}.xcarchive" workspace_path = build_dir / "readoor.xcworkspace" diff --git a/backend/services/log_streamer.py b/backend/services/log_streamer.py index 27e2357..9540c68 100644 --- a/backend/services/log_streamer.py +++ b/backend/services/log_streamer.py @@ -2,6 +2,7 @@ import asyncio import json from datetime import datetime +from pathlib import Path from typing import Dict, AsyncGenerator @@ -11,24 +12,30 @@ class LogStreamer: def __init__(self): self._queues: Dict[str, asyncio.Queue] = {} self._subscribers: Dict[str, list] = {} + self._log_lines: Dict[str, list] = {} def create_queue(self, task_id: str) -> asyncio.Queue: """创建任务的日志队列""" queue = asyncio.Queue(maxsize=1000) self._queues[task_id] = queue + self._log_lines[task_id] = [] return queue async def emit(self, task_id: str, message: str, level: str = "info"): """发送日志消息""" - if task_id not in self._queues: - return - log_entry = { "timestamp": datetime.now().isoformat(), "level": level, "message": message, } + # 收集日志 + if task_id in self._log_lines: + self._log_lines[task_id].append(log_entry) + + if task_id not in self._queues: + return + try: self._queues[task_id].put_nowait(log_entry) except asyncio.QueueFull: @@ -81,6 +88,27 @@ 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) + if not lines: + return + + log_path = build_dir / "build.log" + with open(log_path, "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) -> str: + """读取已保存的打包日志""" + log_path = build_dir / "build.log" + if log_path.exists(): + return log_path.read_text(encoding="utf-8") + return "" + # 全局单例 log_streamer = LogStreamer() diff --git a/frontend/src/views/BuildView.vue b/frontend/src/views/BuildView.vue index bab2afe..5a7d4d2 100644 --- a/frontend/src/views/BuildView.vue +++ b/frontend/src/views/BuildView.vue @@ -173,6 +173,27 @@ const fetchTaskDetail = async (taskId) => { } } +const fetchBuildLog = async (taskId) => { + 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) + logs.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 (e) { + // ignore + } +} + const connectWs = (taskId) => { if (activeWs) { activeWs.close() @@ -215,6 +236,12 @@ const connectWs = (taskId) => { } } } + ws.onclose = () => { + // WebSocket 关闭后,尝试从服务器获取完整日志 + if (logs.value.length === 0) { + fetchBuildLog(taskId) + } + } } watch(() => currentTaskId.value, (newId) => { @@ -248,7 +275,10 @@ onMounted(async () => { // 已完成/失败的任务,直接显示结果 if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { completedTask.value = task - logs.value = [{ timestamp: task.completed_at || task.created_at, level: task.status === 'failed' ? 'error' : 'step', message: task.status === 'failed' ? `打包失败: ${task.error_message || ''}` : '打包完成' }] + await fetchBuildLog(queryTaskId) + if (!logs.value.length) { + logs.value = [{ timestamp: task.completed_at || task.created_at, level: task.status === 'failed' ? 'error' : 'step', message: task.status === 'failed' ? `打包失败: ${task.error_message || ''}` : '打包完成' }] + } } // 设置 currentTaskId(触发 watch → connectWs,用 flag 跳过) skipNextConnect = true diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index 2e1d29d..7408ee8 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -230,6 +230,20 @@

打包设置

+

应用版本号

+
+ + +
格式:主版本.次版本.修订号
+
+
+ + +
格式:主版本.次版本.修订号.构建号(App_Store 打包时构建号自动 +1)
+
+ + +

打包参数

@@ -439,6 +453,7 @@ const servers = ref({}) const branches = ref([]) const newBranch = ref('') const buildSettings = ref({}) +const versions = ref({ app_ver: '', build_ver: '' }) const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} }) const jsonContent = ref('{}') @@ -459,7 +474,7 @@ onMounted(async () => { }) const loadData = async () => { - const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes] = await Promise.all([ + const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes] = await Promise.all([ fetch('/api/config/apps'), fetch('/api/config/schemes'), fetch('/api/config/servers'), @@ -467,6 +482,7 @@ const loadData = async () => { fetch('/api/config/build'), fetch('/api/config/upload'), fetch('/api/config'), + fetch('/api/config/versions'), ]) apps.value = await appsRes.json() schemes.value = await schemesRes.json() @@ -475,6 +491,7 @@ const loadData = async () => { buildSettings.value = await buildRes.json() uploadConfig.value = await uploadRes.json() jsonContent.value = JSON.stringify(await configRes.json(), null, 2) + versions.value = await versionsRes.json() } // 服务器环境管理 @@ -724,6 +741,26 @@ const saveBuildSettings = async () => { alert('设置已保存') } +const saveVersions = async () => { + try { + const res = await fetch('/api/config/versions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(versions.value), + }) + if (res.ok) { + const data = await res.json() + versions.value = { app_ver: data.app_ver, build_ver: data.build_ver } + alert('版本号已保存') + } else { + const err = await res.json() + alert(err.detail || '保存失败') + } + } catch (e) { + alert('保存失败') + } +} + const saveUploadConfig = async () => { try { await fetch('/api/config/upload', {