"""桌面客户端在线升级接口。 公开接口(桌面客户端启动时调用,无需登录): GET /api/desktop/latest 返回最新版本清单 {version,url,force,notes},无发布时返回 {} GET /api/desktop/download 下载安装包 管理员接口(仅 admin): GET /api/desktop/release 读取当前发布配置 PUT /api/desktop/release 修改版本号/强制升级/更新说明 POST /api/desktop/release/installer 上传安装包(.exe) DELETE /api/desktop/release/installer 删除安装包(即停用在线升级) """ from __future__ import annotations import os from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from auth.dependencies import require_admin from auth.system_settings import get_cached_settings from desktop_release import ( INSTALLER_DIR, installer_exists, installer_path, load_release, package_ready, remove_installer, save_release, ) from models.database import get_db from models.models import User router = APIRouter(prefix="/api/desktop", tags=["desktop-update"]) _MAX_INSTALLER_BYTES = 500 * 1024 * 1024 # 500MB 上限,足够覆盖安装包 class DesktopReleaseResponse(BaseModel): version: str = "" force: bool = False notes: str = "" installer_name: str = "" installer_size: int = 0 installer_url: str = "" updated_at: str = "" has_installer: bool = False # 本地是否已上传安装包文件 package_ready: bool = False # 是否具备可下载安装包(外链或本地文件) download_url: str = "" # 本地上传文件的下载地址 class DesktopReleaseUpdate(BaseModel): version: str | None = Field(default=None, max_length=40) force: bool | None = None notes: str | None = Field(default=None, max_length=4000) installer_url: str | None = Field(default=None, max_length=1000) def _public_base(request: Request) -> str: """构造给客户端用的绝对地址:优先后台配置的 app_url,本地址兜底用请求来源。""" try: app_url = get_cached_settings().app_url_normalized() except Exception: app_url = "" if not app_url or "localhost" in app_url or "127.0.0.1" in app_url: app_url = str(request.base_url).rstrip("/") return app_url def _to_response(data, request: Request) -> DesktopReleaseResponse: return DesktopReleaseResponse( version=data.version, force=data.force, notes=data.notes, installer_name=data.installer_name, installer_size=data.installer_size, installer_url=(data.installer_url or "").strip(), updated_at=data.updated_at, has_installer=installer_exists(), package_ready=package_ready(data), download_url=f"{_public_base(request)}/api/desktop/download", ) # ---------- 公开接口 ---------- @router.get("/latest") async def desktop_latest(request: Request, db: AsyncSession = Depends(get_db)): """桌面客户端启动检查更新。未发布或安装包缺失时返回空对象。""" data = await load_release(db) if not data.version or not package_ready(data): return {} external = (data.installer_url or "").strip() download_url = external or f"{_public_base(request)}/api/desktop/download" return { "version": data.version, "url": download_url, "force": bool(data.force), "notes": data.notes or "", } @router.get("/download") async def desktop_download(db: AsyncSession = Depends(get_db)): """下载安装包。""" if not installer_exists(): raise HTTPException(status_code=404, detail="安装包不存在") return FileResponse( installer_path(), media_type="application/octet-stream", filename="DouyinHostedDesktop-Setup.exe", ) # ---------- 管理员接口 ---------- @router.get("/release", response_model=DesktopReleaseResponse) async def get_release( request: Request, db: AsyncSession = Depends(get_db), _: User = Depends(require_admin), ): data = await load_release(db) return _to_response(data, request) @router.put("/release", response_model=DesktopReleaseResponse) async def update_release( body: DesktopReleaseUpdate, request: Request, db: AsyncSession = Depends(get_db), _: User = Depends(require_admin), ): updates = body.model_dump(exclude_unset=True) if "version" in updates and updates["version"] is not None: updates["version"] = updates["version"].strip() if "notes" in updates and updates["notes"] is not None: updates["notes"] = updates["notes"].strip() if "installer_url" in updates and updates["installer_url"] is not None: url = updates["installer_url"].strip() if url and not url.lower().startswith(("http://", "https://")): raise HTTPException(status_code=400, detail="安装包网址需以 http:// 或 https:// 开头") updates["installer_url"] = url data = await save_release(db, updates) return _to_response(data, request) @router.post("/release/installer", response_model=DesktopReleaseResponse) async def upload_installer( request: Request, file: UploadFile = File(...), db: AsyncSession = Depends(get_db), _: User = Depends(require_admin), ): filename = (file.filename or "").strip() if not filename.lower().endswith(".exe"): raise HTTPException(status_code=400, detail="请上传 .exe 安装包") os.makedirs(INSTALLER_DIR, exist_ok=True) tmp_path = installer_path() + ".uploading" size = 0 try: with open(tmp_path, "wb") as f: while True: chunk = await file.read(1024 * 1024) if not chunk: break size += len(chunk) if size > _MAX_INSTALLER_BYTES: raise HTTPException(status_code=400, detail="安装包超过 500MB 上限") f.write(chunk) except HTTPException: if os.path.exists(tmp_path): os.remove(tmp_path) raise except Exception as exc: # noqa: BLE001 if os.path.exists(tmp_path): os.remove(tmp_path) raise HTTPException(status_code=500, detail=f"保存安装包失败: {exc}") from exc if size == 0: if os.path.exists(tmp_path): os.remove(tmp_path) raise HTTPException(status_code=400, detail="安装包为空") os.replace(tmp_path, installer_path()) data = await save_release(db, {"installer_name": filename, "installer_size": size}) return _to_response(data, request) @router.delete("/release/installer", response_model=DesktopReleaseResponse) async def delete_installer( request: Request, db: AsyncSession = Depends(get_db), _: User = Depends(require_admin), ): remove_installer() data = await save_release(db, {"installer_name": "", "installer_size": 0}) return _to_response(data, request)