202 lines
7.0 KiB
Python
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)
|