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
+2
View File
@@ -10,6 +10,8 @@ from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
def license_key_encryption_material() -> bytes:
# 数据库只存 License Key 的密文和 hash。
# hash 用于客户端提交 License 时快速匹配;密文用于后台列表需要再次展示原始 Key 的场景。
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
if configured_secret:
return configured_secret.encode("utf-8")
+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)
+4
View File
@@ -21,11 +21,15 @@ def load_manifest_private_key():
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
# 签名之前必须先规范化 JSON:固定字段顺序、去掉空格、排除 signature 字段。
# 否则同一份 Manifest 在不同环境下序列化结果不同,会导致客户端验签失败。
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def sign_manifest(manifest_obj: dict) -> str:
# 服务端只保存私钥,客户端只分发公钥。
# 客户端能验证 Manifest 确实由服务端签发,但无法伪造新的签名。
json_text = canonical_manifest_bytes(manifest_obj)
private_key = load_manifest_private_key()
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())