Files
douyin/backend/desktop_router.py
2026-07-17 09:24:47 +08:00

202 lines
7.0 KiB
Python

"""桌面客户端在线升级接口。
公开接口(桌面客户端启动时调用,无需登录):
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)