feat: 构建超时控制、日志持久化、任务删除、pod install 及多项 UI 优化

- 新增 BUILD_TIMEOUT_HOURS 配置,超时自动标记失败
- 日志保存到独立 backend/logs/ 目录,不随构建目录删除
- 新增任务删除 API 及前端删除按钮
- 构建流程增加 pod install 步骤
- 通过文件传递配置避免命令行参数截断
- Provisioning Profile 支持按名称搜索
- dSYM 下载支持目录打包为 zip
- 前端端口可配置、历史页增加下载列和耗时显示
- 日志弹窗支持展开/收起详细日志
- 自动生成 upload_key 标识
This commit is contained in:
shen
2026-06-08 18:56:43 +08:00
parent 75363ee276
commit f5919a8229
11 changed files with 535 additions and 212 deletions
+4
View File
@@ -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")
+19
View File
@@ -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)
+92 -6
View File
@@ -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")
+1
View File
@@ -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
+209 -100
View File
@@ -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
+34 -8
View File
@@ -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 ""