import hashlib import json import os from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse import minio_tool from app.core.security import admin_auth from app.repositories import version_repository from app.schemas.version import VersionIdRequest, VersionProtocolRequest from app.services.common_service import is_executable_path from app.services.signing_service import canonical_manifest_bytes, sign_manifest, sign_policy TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows") TARGET_ARCH = os.getenv("TARGET_ARCH", "x64") SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1") router = APIRouter(tags=["admin-version"]) @router.post("/admin/version/offline-package") def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)): version_id = int(body.version_id or 0) ver, rows = version_repository.get_version_with_files(version_id) if not ver or not rows: raise HTTPException(status_code=404, detail="版本或版本文件不存在") files = [{"path": r["path"], "sha256": r["sha256"], "size": r["size"], "executable": is_executable_path(r["path"])} for r in rows] manifest = { "app_id": ver["app_id"], "version": ver["version"], "channel": ver["channel"], "platform": TARGET_PLATFORM, "arch": TARGET_ARCH, "manifest_seq": version_id, "created_at": ver["create_time"], "files": files, } manifest_text = canonical_manifest_bytes(manifest).decode() manifest_signature = sign_manifest(manifest) offset = 0 entries = [] for r in rows: entries.append({"path": r["path"], "offset": offset, "size": r["size"], "sha256": r["sha256"]}) offset += int(r["size"] or 0) package = { "format": "MUPD0001", "app_id": ver["app_id"], "channel": ver["channel"], "version": ver["version"], "version_id": version_id, "created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "manifest_sha256": hashlib.sha256(manifest_text.encode()).hexdigest(), "payload_size": offset, "files": entries, "signature_alg": "RSA-2048-SHA256", "key_id": SIGNING_KEY_ID, } package_text, package_signature = sign_policy(package) wrapper = json.dumps( { "package_text": package_text, "package_signature": package_signature, "manifest_text": manifest_text, "manifest_signature": manifest_signature, }, ensure_ascii=False, separators=(",", ":"), ).encode() prefix = f"{ver['app_id']}/{ver['channel']}/{ver['version']}/files/" def stream(): yield b"MUPD0001" yield len(wrapper).to_bytes(8, "little") yield wrapper for r in rows: yield from minio_tool.iter_file(prefix + r["path"]) filename = f"{ver['app_id']}_{ver['channel']}_{ver['version']}.upd" return StreamingResponse( stream(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"', "Content-Length": str(16 + len(wrapper) + offset)}, ) @router.get("/admin/version/list") def admin_get_version_list(app_id: str, auth=Depends(admin_auth)): return {"list": version_repository.list_versions(app_id)} @router.post("/admin/version/set-protocol") def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_auth)): version_id = int(body.version_id or 0) client_protocol = int(body.client_protocol or 0) if version_id < 1 or client_protocol < 1: raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数") updated = version_repository.set_version_protocol(version_id, client_protocol) if updated != 1: raise HTTPException(status_code=404, detail="版本不存在") return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol} @router.post("/admin/version/set-latest") def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)): vid = body.version_id result = version_repository.set_latest_version(vid) if result == "not_found": raise HTTPException(status_code=404, detail="版本不存在") return {"msg": "已切换为渠道最新版本"} @router.post("/admin/version/delete") def admin_delete_version(body: VersionIdRequest, auth=Depends(admin_auth)): vid = body.version_id v_info = version_repository.get_version_for_delete(vid) if not v_info: raise HTTPException(status_code=404, detail="版本不存在") aid, ch, ver = v_info["app_id"], v_info["channel"], v_info["version"] was_latest = bool(v_info["latest"]) prefix = f"{aid}/{ch}/{ver}/files/" minio_tool.remove_prefix(prefix) promoted_version = version_repository.delete_version_and_promote(vid, aid, ch, was_latest) return {"msg": "版本、云端文件、数据库记录全部删除完成", "promoted_latest": promoted_version}