feat: 版本号管理、构建日志持久化、App_Store 构建号自动递增

- 新增版本号配置 API(GET/PUT /api/config/versions)和前端管理界面
- 构建日志收集与保存:任务完成/取消/失败时写入 build.log
- 新增任务日志查询接口 GET /api/tasks/{task_id}/log
- App_Store 打包时自动递增 Build_Ver 第四位版本号
- 从 Provisioning Profile 自动提取 TEAM_ID 和 PROVISIONING_NAME
- 前端完成后支持查看完整构建日志
This commit is contained in:
shen
2026-06-08 11:23:15 +08:00
parent 898ec01560
commit 75363ee276
6 changed files with 243 additions and 15 deletions
+62 -10
View File
@@ -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"
+31 -3
View File
@@ -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()