feat: 完善服务端工程化、发布流程和自动化测试

This commit is contained in:
2026-07-16 08:36:12 +00:00
parent 0b71e8d024
commit 5d7aa560e5
29 changed files with 822 additions and 7987 deletions
+61 -1
View File
@@ -108,6 +108,8 @@ def should_skip_release_directory(part: str) -> bool:
def should_skip_release_file(relative_path: str) -> bool:
folded = relative_path.casefold()
# 发布包里不能带客户端本机运行态文件。
# 这些文件包含 License、设备身份、策略缓存等机器相关数据,应由客户端运行时生成或服务端重新签发。
runtime_protected_files = {
"client.ini",
"bootstrap.exe",
@@ -163,6 +165,8 @@ def supported_archive_kind(filename: str) -> str:
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
# 解压压缩包时必须防止 Zip Slip / 路径穿越。
# 任何 ../ 或绝对路径都不能写出临时解压目录。
target = (root / relative_path).resolve()
root_resolved = root.resolve()
if target != root_resolved and root_resolved not in target.parents:
@@ -304,6 +308,8 @@ def release_items_from_extracted_dir(extract_dir: Path, required_main_executable
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
# 有些压缩包会多包一层顶级目录,例如 SimCAE-1.0.0/bin/SimCAE.exe。
# 如果所有文件都在同一个顶级目录下,并且能找到主程序,就自动剥掉这一层,降低发布者操作成本。
if not items:
return items
required_main_key = required_main_executable.casefold()
@@ -417,6 +423,7 @@ async def close_upload_safely(upload):
def ensure_publish_storage_space(next_file_size: int):
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
MINIO_DATA_DIR.mkdir(parents=True, exist_ok=True)
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
if available_bytes < required_bytes:
raise HTTPException(
@@ -431,6 +438,8 @@ def ensure_publish_storage_space(next_file_size: int):
async def collect_publish_items(request: Request):
# 发布入口支持两种方式:浏览器选择 Release 目录,或上传一个压缩发布包。
# 这里先做请求大小、临时目录空间和 MinIO 存储空间检查,避免大包把服务器拖死。
content_length_header = request.headers.get("content-length")
try:
content_length = int(content_length_header or 0)
@@ -595,6 +604,7 @@ async def collect_publish_items(request: Request):
"upload_items": upload_items,
"archive_upload": archive_upload,
"archive_temp_dir": archive_temp_dir,
"job_temp_dir": None,
}
@@ -610,11 +620,55 @@ def cleanup_path(path: Path | None):
print(f"清理临时文件失败: {path}: {err}")
async def publish_version(request: Request):
async def make_background_publish_payload(request: Request):
# 后台发布任务不能继续持有浏览器上传流,所以目录模式要先复制到服务端临时目录。
# 压缩包模式在 collect_publish_items 中已经解压为本地文件,只需要保留解压目录到任务结束。
payload = await collect_publish_items(request)
job_temp_dir = Path(tempfile.mkdtemp(prefix="publish_job_", dir=UPLOAD_SPOOL_DIR))
converted_items = []
try:
for item in payload["upload_items"]:
if "upload" not in item:
converted_items.append(item)
continue
upload = item["upload"]
target = ensure_inside_directory(job_temp_dir, item["path"])
target.parent.mkdir(parents=True, exist_ok=True)
try:
upload.file.seek(0)
with target.open("wb") as output:
shutil.copyfileobj(upload.file, output, length=1024 * 1024)
except OSError as err:
raise HTTPException(status_code=500, detail={"error": "publish_job_spool_failed", "msg": str(err)})
finally:
await close_upload_safely(upload)
converted_items.append({"path": item["path"], "local_path": target})
payload["upload_items"] = converted_items
payload["job_temp_dir"] = job_temp_dir
payload["archive_upload"] = None
return payload
except Exception:
for item in payload.get("upload_items", []):
upload = item.get("upload") if isinstance(item, dict) else None
if upload is not None:
await close_upload_safely(upload)
archive_upload = payload.get("archive_upload")
if archive_upload is not None:
await close_upload_safely(archive_upload)
cleanup_path(payload.get("archive_temp_dir"))
cleanup_path(job_temp_dir)
raise
async def publish_payload(payload: dict):
# 完整发布事务:
# 1. 使用已收集的发布文件;2. 创建版本记录;3. 上传文件到 MinIO4. 写入文件 hash/size。
# 任何一步失败都会回滚数据库,并删除已经上传的对象前缀,避免出现“半个版本”。
upload_items = payload["upload_items"]
archive_upload = payload["archive_upload"]
archive_temp_dir = payload["archive_temp_dir"]
job_temp_dir = payload.get("job_temp_dir")
app_id = payload["app_id"]
channel = payload["channel"]
version = payload["version"]
@@ -689,3 +743,9 @@ async def publish_version(request: Request):
if archive_upload is not None:
await close_upload_safely(archive_upload)
cleanup_path(archive_temp_dir)
cleanup_path(job_temp_dir)
async def publish_version(request: Request):
payload = await collect_publish_items(request)
return await publish_payload(payload)