feat: 桌面更新支持OSS预签名直传与安装包确认登记
This commit is contained in:
@@ -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 对象 key(presign 返回的 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,
|
||||
|
||||
Reference in New Issue
Block a user