""" 应用更新 API ============ 为 Tauri updater 插件提供更新检查接口。 """ from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.session import get_db from app.models.update import AppRelease, ReleasePackage from app.schemas.update import ( PackageInfo, ReleaseCreate, ReleaseListItem, ReleaseResponse, TauriPlatformInfo, TauriUpdateResponse, ) router = APIRouter() @router.get("/check", response_model=TauriUpdateResponse) async def check_update( version: str = Query(..., description="当前应用版本"), target: str = Query(..., description="平台:darwin / windows / linux"), arch: str = Query(..., description="架构:x86_64 / aarch64"), db: AsyncSession = Depends(get_db), ): """ 检查应用更新 — Tauri updater 插件入口 Tauri 启动时会向此接口发送请求,携带当前版本、平台、架构信息。 如果无需更新,返回 204;如果有更新,返回 Tauri 标准格式的 JSON。 """ # 查询最新版本 result = await db.execute(select(AppRelease).order_by(AppRelease.release_date.desc()).limit(1)) latest: AppRelease | None = result.scalar_one_or_none() if not latest: return Response(status_code=status.HTTP_204_NO_CONTENT) # 已是最新版本(或更高) if latest.version == version: return Response(status_code=status.HTTP_204_NO_CONTENT) # 查询对应平台的包(优先返回 updater 用的包:有 signature 的 .app.tar.gz / .exe) result = await db.execute( select(ReleasePackage) .where( ReleasePackage.release_id == latest.id, ReleasePackage.platform == target, ReleasePackage.architecture == arch, ) .order_by( # 有 signature 的排前面(updater 包),空 signature 的排后面(dmg 安装包) ReleasePackage.signature.desc() ) ) pkgs = result.scalars().all() # 取第一个:优先有 signature 的 updater 包;如果没有则取任意一个 pkg: ReleasePackage | None = pkgs[0] if pkgs else None if not pkg: # 该平台无包,返回 204(避免报错阻断用户) return Response(status_code=status.HTTP_204_NO_CONTENT) # 构建 Tauri 格式的响应 platform_key = f"{target}-{arch}" return TauriUpdateResponse( version=latest.version, notes=latest.notes, pub_date=latest.release_date.isoformat() if latest.release_date else None, mandatory=latest.mandatory, platforms={ platform_key: TauriPlatformInfo( url=pkg.file_url, signature=pkg.signature, ) }, ) def _parse_user_agent(user_agent: str | None) -> tuple[str, str] | None: """ 从 User-Agent 解析 Tauri 平台标识和架构。 Tauri updater 使用的 platform 值为:darwin / windows / linux architecture 值为:x86_64 / aarch64 / i686 """ if not user_agent: return None ua = user_agent.lower() # Windows if "windows" in ua: platform = "windows" if "arm64" in ua or "aarch64" in ua: arch = "aarch64" elif "win64" in ua or "x64" in ua: arch = "x86_64" else: arch = "x86_64" return platform, arch # macOS if "macintosh" in ua or "mac os x" in ua: platform = "darwin" if "arm64" in ua or "aarch64" in ua: arch = "aarch64" elif "intel" in ua: arch = "x86_64" else: # 现代 Mac 默认按 Apple Silicon 处理; # 若浏览器/Rosetta 环境未暴露 arm64,可让用户手动选择 arch = "aarch64" return platform, arch # Linux if "linux" in ua: platform = "linux" if "aarch64" in ua or "arm64" in ua: arch = "aarch64" elif "x86_64" in ua or "x64" in ua: arch = "x86_64" else: arch = "x86_64" return platform, arch return None @router.get("/download") async def download_latest( request: Request, target: str | None = Query(None, description="平台:darwin / windows / linux"), arch: str | None = Query(None, description="架构:x86_64 / aarch64 / i686"), db: AsyncSession = Depends(get_db), ): """ 统一下载入口:自动匹配最新版本和当前环境安装包。 优先级: 1. 查询参数 target + arch(最可靠,推荐前端显式传入) 2. User-Agent 解析(兜底) 返回 302 重定向到对应安装包的存储地址。 """ # 1. 确定平台和架构 if target and arch: platform = target.lower() architecture = arch.lower() else: parsed = _parse_user_agent(request.headers.get("user-agent")) if not parsed: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="无法识别您的操作系统,请通过官网或应用商店下载对应版本", ) platform, architecture = parsed # 2. 查询最新版本 result = await db.execute(select(AppRelease).order_by(AppRelease.release_date.desc()).limit(1)) latest: AppRelease | None = result.scalar_one_or_none() if not latest: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="暂无可用下载", ) # 3. 查询该平台所有包(不限制架构,因为 macOS 常用 universal 包会同时写入 x86_64/aarch64) result = await db.execute( select(ReleasePackage).where( ReleasePackage.release_id == latest.id, ReleasePackage.platform == platform, ) ) platform_pkgs = list(result.scalars().all()) if not platform_pkgs: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"版本 {latest.version} 暂无可用的 {platform} 安装包", ) # 4. 优先选择用户安装包,而不是 updater 用的 .app.tar.gz # 优先级:.dmg / .exe / .msi / .AppImage > .app.tar.gz > 其他 def _install_pkg_priority(pkg: ReleasePackage) -> int: name = pkg.filename.lower() if name.endswith(".dmg"): return 1 if name.endswith(".exe"): return 2 if name.endswith(".msi"): return 3 if name.endswith(".appimage"): return 4 if name.endswith(".app.tar.gz"): return 10 return 5 # 先尝试精确架构 + 高优先级安装包 exact_arch_pkgs = [p for p in platform_pkgs if p.architecture == architecture] candidate_pkgs = exact_arch_pkgs or platform_pkgs pkg = min(candidate_pkgs, key=_install_pkg_priority) return RedirectResponse(url=pkg.file_url) @router.post("/releases", response_model=ReleaseResponse, status_code=status.HTTP_201_CREATED) async def create_release( release: ReleaseCreate, db: AsyncSession = Depends(get_db), ): """ 创建新版本发布(管理员接口) 用于手动发版时,将构建产物信息写入数据库。 """ # 检查版本是否已存在 result = await db.execute(select(AppRelease).where(AppRelease.version == release.version)) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"版本 {release.version} 已存在", ) # 创建发布记录 new_release = AppRelease( version=release.version, release_date=datetime.now(UTC), notes=release.notes, mandatory=release.mandatory, ) db.add(new_release) await db.flush() # 创建包记录 for pkg in release.packages: db.add( ReleasePackage( release_id=new_release.id, platform=pkg.platform, architecture=pkg.architecture, filename=pkg.filename, file_url=pkg.file_url, file_size=pkg.file_size, signature=pkg.signature, ) ) await db.commit() await db.refresh(new_release) return ReleaseResponse( id=new_release.id, version=new_release.version, release_date=new_release.release_date, notes=new_release.notes, mandatory=new_release.mandatory, created_at=new_release.created_at, packages=[ PackageInfo( platform=p.platform, architecture=p.architecture, filename=p.filename, file_url=p.file_url, file_size=p.file_size, signature=p.signature, ) for p in new_release.packages ], ) @router.get("/releases", response_model=list[ReleaseListItem]) async def list_releases( db: AsyncSession = Depends(get_db), ): """获取所有版本发布列表(管理员接口)""" result = await db.execute(select(AppRelease).order_by(AppRelease.release_date.desc())) releases = result.scalars().all() return [ ReleaseListItem( id=r.id, version=r.version, release_date=r.release_date, notes=r.notes, mandatory=r.mandatory, package_count=len(r.packages), ) for r in releases ] @router.delete("/releases/{version}") async def delete_release( version: str, db: AsyncSession = Depends(get_db), ): """删除版本发布(管理员接口)""" result = await db.execute(select(AppRelease).where(AppRelease.version == version)) release = result.scalar_one_or_none() if not release: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"版本 {version} 不存在", ) await db.delete(release) await db.commit() return {"status": "success", "message": f"版本 {version} 已删除"}