feat: 桌面更新支持OSS预签名直传与安装包确认登记

This commit is contained in:
Pine
2026-09-08 20:26:28 +08:00
parent 92db5a0802
commit d57a611c54
+178
View File
@@ -70,6 +70,33 @@ class DesktopRolloutRequest(BaseModel):
rollout: int = Field(..., ge=0, le=100)
class DesktopPresignRequest(BaseModel):
"""OSS 预签名直传请求:前端先拿临时 PUT URL,直传 OSS 后再 confirm 登记。"""
target: str = Field(..., description="平台 target,如 darwin-aarch64")
filename: str = Field(..., description="原始文件名(用于拼 OSS key")
content_type: str = Field("application/octet-stream", description="Content-Type")
expires_in: int = Field(600, ge=60, le=3600, description="预签名 URL 有效期(秒)")
class DesktopArtifactConfirmRequest(BaseModel):
"""更新包直传完成后确认登记。"""
target: str
key: str = Field(..., description="OSS 对象 keypresign 返回的 key")
filename: str
sha256: str
size: int = Field(..., ge=0)
signature_text: str = Field("", description=".sig 文件内容(明文 minisign 或 Tauri v2 base64")
class DesktopInstallerConfirmRequest(BaseModel):
"""标准安装包直传完成后确认登记(无签名)。"""
target: str
key: str
filename: str
sha256: str
size: int = Field(..., ge=0)
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
@@ -472,6 +499,157 @@ async def delete_desktop_installer(
return updated
# ---------------------------------------------------------------------------
# OSS 预签名直传(前端直传 OSS,绕过后端带宽;confirm 时登记元信息)
# ---------------------------------------------------------------------------
def _validate_oss_key(key: str, expected_prefix: str) -> str:
"""校验 OSS key:非空、无目录穿越、前缀匹配。"""
key = (key or "").strip().strip("/")
if not key or ".." in key.split("/"):
raise HTTPException(status_code=400, detail=f"非法 OSS key: {key!r}")
if not key.startswith(expected_prefix):
raise HTTPException(status_code=400, detail=f"key 前缀应为 {expected_prefix}/")
return key
@router.post("/desktop-versions/{vid}/artifacts/presign", summary="生成更新包 OSS 预签名直传链接")
async def presign_desktop_artifact(
vid: str,
payload: DesktopPresignRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="已下架版本不可再上传产物")
if not oss.enabled:
raise HTTPException(status_code=503, detail="OSS 未配置,无法生成直传链接;请使用服务端中转上传")
fname = Path(payload.filename).name or f"artifact-{secrets.token_hex(4)}"
key = f"desktop-updates/{version['version']}/{payload.target}/{fname}"
upload_url = await oss.presigned_put(
key, expires=payload.expires_in, content_type=payload.content_type
)
await write_audit(
db, action="desktop_version.artifact_presign", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} key={key}",
user=actor,
)
return {"upload_url": upload_url, "key": key, "method": "PUT", "expires_in": payload.expires_in}
@router.post("/desktop-versions/{vid}/artifacts/confirm", summary="确认更新包直传完成并登记")
async def confirm_desktop_artifact(
vid: str,
payload: DesktopArtifactConfirmRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="已下架版本不可再登记产物")
key = _validate_oss_key(payload.key, "desktop-updates/")
if payload.size > MAX_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail=f"安装包超过 {MAX_ARTIFACT_BYTES // 1024 // 1024 // 1024}GB 限制")
signature = ""
if payload.signature_text:
signature = _verify_signature(payload.signature_text)
artifacts = dict(version.get("artifacts") or {})
artifacts[payload.target] = {
"filename": Path(payload.filename).name,
"url": f"/desktop-updates/files/{key}",
"signature": signature,
"sha256": payload.sha256,
"size": payload.size,
}
updated = await db.desktop_versions.update(
vid, {"artifacts_json": json.dumps(artifacts, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.artifact_confirm", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} {payload.size}B sha256={payload.sha256[:12]}",
user=actor,
)
return updated
@router.post("/desktop-versions/{vid}/installers/presign", summary="生成标准安装包 OSS 预签名直传链接")
async def presign_desktop_installer(
vid: str,
payload: DesktopPresignRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="已下架版本不可再上传安装包")
if not oss.enabled:
raise HTTPException(status_code=503, detail="OSS 未配置,无法生成直传链接;请使用服务端中转上传")
fname = Path(payload.filename).name or f"installer-{secrets.token_hex(4)}"
expect_ext = _installer_extension(payload.target)
if not fname.lower().endswith(expect_ext.lower()):
raise HTTPException(
status_code=400,
detail=f"target={payload.target} 的安装包扩展名应为 {expect_ext}(当前: {Path(fname).suffix or '(无)'}",
)
key = f"desktop-installers/{version['version']}/{payload.target}/{fname}"
upload_url = await oss.presigned_put(
key, expires=payload.expires_in, content_type=payload.content_type
)
await write_audit(
db, action="desktop_version.installer_presign", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} key={key}",
user=actor,
)
return {"upload_url": upload_url, "key": key, "method": "PUT", "expires_in": payload.expires_in}
@router.post("/desktop-versions/{vid}/installers/confirm", summary="确认标准安装包直传完成并登记")
async def confirm_desktop_installer(
vid: str,
payload: DesktopInstallerConfirmRequest,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
):
if payload.target not in DESKTOP_TARGETS:
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
version = await _get_version(db, vid)
if version["status"] == "archived":
raise HTTPException(status_code=400, detail="已下架版本不可再登记安装包")
key = _validate_oss_key(payload.key, "desktop-installers/")
if payload.size > MAX_ARTIFACT_BYTES:
raise HTTPException(status_code=413, detail=f"安装包超过 {MAX_ARTIFACT_BYTES // 1024 // 1024 // 1024}GB 限制")
installers = dict(version.get("installers") or {})
installers[payload.target] = {
"filename": Path(payload.filename).name,
"url": f"/desktop-installers/files/{key}",
"sha256": payload.sha256,
"size": payload.size,
}
updated = await db.desktop_versions.update(
vid, {"installers_json": json.dumps(installers, ensure_ascii=False)}
)
await write_audit(
db, action="desktop_version.installer_confirm", resource="desktop_version", resource_id=vid,
detail=f"version={version['version']} target={payload.target} {payload.size}B sha256={payload.sha256[:12]}",
user=actor, request=request,
)
return updated
@router.post("/desktop-versions/{vid}/publish", summary="发布(全量或按灰度比例)")
async def publish_desktop_version(
vid: str,