From d57a611c54ab69aa4bbd8aab022d6f0806830b90 Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 8 Sep 2026 20:26:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A1=8C=E9=9D=A2=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=94=AF=E6=8C=81OSS=E9=A2=84=E7=AD=BE=E5=90=8D=E7=9B=B4?= =?UTF-8?q?=E4=BC=A0=E4=B8=8E=E5=AE=89=E8=A3=85=E5=8C=85=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routers/rbac_desktop_updates.py | 178 ++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/app/api/routers/rbac_desktop_updates.py b/app/api/routers/rbac_desktop_updates.py index 0828eb7..0b8cbe9 100644 --- a/app/api/routers/rbac_desktop_updates.py +++ b/app/api/routers/rbac_desktop_updates.py @@ -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,