# 应用自动更新系统开发文档 ## 概述 本文档详细说明美家卡智影应用自动更新系统的完整实现方案,采用自建更新服务器 + 七牛云存储的架构,解决国内访问 GitHub 不稳定的问题。 **架构特点**: - 轻量云账号 + 全本地业务数据 - 七牛云存储更新包,稳定访问 - FastAPI 提供更新检查和下载接口 - Tauri 应用通过 API 获取更新并安装 - 支持强制更新、下载统计等功能 --- ## 一、架构设计 ### 1.1 整体架构 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 桌面应用 │ │ ┌──────────────────────┐ ┌──────────────────────────┐ │ │ │ React 前端 │────────▶│ Rust 后端 │ │ │ │ - 更新对话框 │ IPC │ - 检查/下载/安装 │ │ │ └──────────────────────┘ └──────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ HTTP │ ▼ │ ┌─────────────────────────────────────────────────────────────────┐ │ 后端服务层 │ │ ┌──────────────────────┐ ┌──────────────────────────┐ │ │ │ FastAPI 后端 │────────▶│ PostgreSQL 数据库 │ │ │ │ - 更新 API │ │ - 版本/包/下载日志 │ │ │ └──────────────────────┘ └──────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────┐ │ │ │ 七牛云存储 │ │ │ └──────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.2 更新流程 应用启动 → 检查更新 → 显示对话框 → 下载安装包 → 安装 → 重启应用 --- ## 二、数据库设计 ### 2.1 数据表结构 #### 2.1.1 app_releases - 版本发布记录 | 字段 | 类型 | 说明 | 约束 | |------|------|------|------| | id | SERIAL | 主键 | PRIMARY KEY | | version | VARCHAR(20) | 版本号 (语义化版本) | NOT NULL, UNIQUE | | release_date | TIMESTAMP | 发布时间 | NOT NULL | | notes | TEXT | 更新说明 | NOT NULL | | mandatory | BOOLEAN | 是否强制更新 | DEFAULT FALSE | | created_at | TIMESTAMP | 创建时间 | DEFAULT NOW() | **示例数据**: ```sql INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES ('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE), ('0.1.1', '2026-04-14 10:00:00', '新功能:视频字幕压制\n修复:导出问题', FALSE), ('0.2.0', '2026-04-20 10:00:00', '新增:批量导出功能\n优化:性能提升 30%', FALSE); ``` #### 2.1.2 release_packages - 平台包信息 | 字段 | 类型 | 说明 | 约束 | |------|------|------|------| | id | SERIAL | 主键 | PRIMARY KEY | | release_id | INTEGER | 关联版本发布 | FOREIGN KEY → app_releases.id | | platform | VARCHAR(20) | 操作系统平台 | NOT NULL | | architecture | VARCHAR(20) | CPU 架构 | NOT NULL | | filename | VARCHAR(255) | 文件名 | NOT NULL | | file_url | VARCHAR(500) | 七牛云下载 URL | NOT NULL | | file_size | BIGINT | 文件大小(字节) | NOT NULL | | file_hash | VARCHAR(64) | SHA256 哈希 | NOT NULL | | download_count | INTEGER | 下载次数 | DEFAULT 0 | | created_at | TIMESTAMP | 创建时间 | DEFAULT NOW() | **平台枚举值**: - `darwin`: macOS - `windows`: Windows - `linux`: Linux **架构枚举值**: - `x86_64`: 64位 Intel/AMD - `arm64`: ARM64 (Apple Silicon) **示例数据**: ```sql INSERT INTO release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES (2, 'darwin', 'x86_64', 'meijiaka_0.1.1_darwin_x86_64.dmg', 'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_darwin_x86_64.dmg', 102400000, 'sha256:abc123...'), (2, 'windows', 'x86_64', 'meijiaka_0.1.1_windows_x86_64-setup.exe', 'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_windows_x86_64-setup.exe', 115343360, 'sha256:def456...'), (2, 'linux', 'amd64', 'meijiaka_0.1.1_linux_amd64.AppImage', 'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_linux_amd64.AppImage', 104857600, 'sha256:ghi789...'); ``` #### 2.1.3 update_downloads - 更新下载日志 | 字段 | 类型 | 说明 | 约束 | |------| | | | | id | SERIAL | 主键 | PRIMARY KEY | | release_id | INTEGER | 关联版本发布 | FOREIGN KEY → app_releases.id | | platform | VARCHAR(20) | 下载平台 | NOT NULL | | app_version | VARCHAR(20) | 应用当前版本 | NOT NULL | | user_id | INTEGER | 用户 ID (可选) | FOREIGN KEY → users.id | | download_at | TIMESTAMP | 下载时间 | DEFAULT NOW() | ### 2.2 索引设计 ```sql -- 快速查询最新版本 CREATE INDEX idx_releases_release_date ON app_releases(release_date DESC); -- 平台包复合索引 CREATE INDEX idx_packages_platform_arch ON release_packages(platform, architecture); -- 下载统计 CREATE INDEX idx_downloads_release_id ON update_downloads(release_id); ``` ### 2.3 初始化脚本 文件位置:`python-api/scripts/init_update_tables.sql` ```sql -- 创建版本发布记录表 CREATE TABLE IF NOT EXISTS app_releases ( id SERIAL PRIMARY KEY, version VARCHAR(20) NOT NULL UNIQUE, release_date TIMESTAMP NOT NULL, notes TEXT NOT NULL, mandatory BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW() ); -- 创建平台包信息表 CREATE TABLE IF NOT EXISTS release_packages ( id SERIAL PRIMARY KEY, release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE, platform VARCHAR(20) NOT NULL, architecture VARCHAR(20) NOT NULL, filename VARCHAR(255) NOT NULL, file_url VARCHAR(500) NOT NULL, file_size BIGINT NOT NULL, file_hash VARCHAR(64) NOT NULL, download_count INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); -- 创建更新下载日志表 CREATE TABLE IF NOT EXISTS update_downloads ( id SERIAL PRIMARY KEY, release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE, platform VARCHAR(20) NOT NULL, app_version VARCHAR(20) NOT NULL, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, download_at TIMESTAMP DEFAULT NOW() ); -- 创建索引 CREATE INDEX IF NOT EXISTS idx_releases_version ON app_releases(version); CREATE INDEX IF NOT EXISTS idx_releases_release_date ON app_releases(release_date DESC); CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON release_packages(platform, architecture); CREATE INDEX IF NOT EXISTS idx_packages_release_id ON release_packages(release_id); CREATE INDEX IF NOT EXISTS idx_downloads_release_id ON update_downloads(release_id); CREATE INDEX IF NOT EXISTS idx_downloads_download_at ON update_downloads(download_at); -- 插入初始版本(可选) INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES ('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE) ON CONFLICT (version) DO NOTHING; ``` --- ## 三、后端 API 设计 ### 3.1 API 端点列表 | 方法 | 路径 | 说明 | 认证 | |------|------|------|------| | POST | `/api/v1/update/check` | 检查应用更新 | 无需认证 | | GET | `/api/v1/update/download/{version}/{platform}` | 获取下载 URL 并记录 | 无需认证 | | POST | `/api/v1/update/releases` | 创建新版本发布 | 需要管理员认证 | | GET | `/api/v1/update/releases` | 获取所有版本列表 | 需要管理员认证 | | DELETE | `/api/v1/update/releases/{version}` | 删除版本发布 | 需要管理员认证 | ### 3.2 数据模型 #### 3.2.1 SQLAlchemy 模型 文件位置:`python-api/app/models/update.py` ```python from datetime import datetime from typing import Optional from sqlalchemy import DateTime, Boolean, Integer, String, Text, BigInteger, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base class AppRelease(Base): """应用版本发布记录""" __tablename__ = "app_releases" id: Mapped[int] = mapped_column(Integer, primary_key=True) version: Mapped[str] = mapped_column(String(20), unique=True, index=True) release_date: Mapped[datetime] = mapped_column(DateTime, nullable=False) notes: Mapped[str] = mapped_column(Text, nullable=False) mandatory: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) # 关系 packages: Mapped[list["ReleasePackage"]] = relationship( "ReleasePackage", back_populates="release", cascade="all, delete-orphan" ) downloads: Mapped[list["UpdateDownload"]] = relationship( "UpdateDownload", back_populates="release", cascade="all, delete-orphan" ) class ReleasePackage(Base): """平台包信息""" __tablename__ = "release_packages" id: Mapped[int] = mapped_column(Integer, primary_key=True) release_id: Mapped[int] = mapped_column( Integer, ForeignKey("app_releases.id", ondelete="CASCADE"), nullable=False ) platform: Mapped[str] = mapped_column(String(20), nullable=False) architecture: Mapped[str] = mapped_column(String(20), nullable=False) filename: Mapped[str] = mapped_column(String(255), nullable=False) file_url: Mapped[str] = mapped_column(String(500), nullable=False) file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) file_hash: Mapped[str] = mapped_column(String(64), nullable=False) download_count: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) # 关系 release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages") class UpdateDownload(Base): """更新下载日志""" __tablename__ = "update_downloads" id: Mapped[int] = mapped_column(Integer, primary_key=True) release_id: Mapped[int] = mapped_column( Integer, ForeignKey("app_releases.id", ondelete="CASCADE"), nullable=False ) platform: Mapped[str] = mapped_column(String(20), nullable=False) app_version: Mapped[str] = mapped_column(String(20), nullable=False) user_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("users.id", ondelete="SET NULL") ) download_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) # 关系 release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="downloads") ``` #### 3.2.2 Pydantic Schemas 文件位置:`python-api/app/schemas/update.py` ```python from datetime import datetime from typing import Optional from pydantic import BaseModel, Field class UpdateCheckRequest(BaseModel): """更新检查请求""" current_version: str = Field(..., description="当前应用版本") platform: str = Field(..., description="操作系统平台: darwin, windows, linux") architecture: Optional[str] = Field(None, description="CPU架构: x86_64, arm64") class PackageInfo(BaseModel): """包信息""" platform: str architecture: str file_url: str file_size: int file_hash: str class UpdateInfoResponse(BaseModel): """更新信息响应""" version: str release_date: datetime notes: str mandatory: bool packages: list[PackageInfo] class PackageCreate(BaseModel): """包创建信息""" platform: str architecture: str file_url: str file_size: int file_hash: str class ReleaseCreate(BaseModel): """版本发布创建请求""" version: str notes: str mandatory: bool = False packages: list[PackageCreate] class ReleaseResponse(BaseModel): """版本发布响应""" id: int version: str release_date: datetime notes: str mandatory: bool created_at: datetime packages: list[PackageInfo] class DownloadResponse(BaseModel): """下载信息响应""" download_url: str file_size: int file_hash: str ``` ### 3.3 API 路由实现 文件位置:`python-api/app/api/v1/update.py` ```python from fastapi import APIRouter, HTTPException, Depends, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ from typing import Optional from ...deps import get_db from ...models.update import AppRelease, ReleasePackage, UpdateDownload from ...schemas.update import ( UpdateCheckRequest, UpdateInfoResponse, ReleaseCreate, ReleaseResponse, DownloadResponse, PackageInfo, PackageCreate ) router = APIRouter() @router.post("/check", response_model=UpdateInfoResponse, status_code=status.HTTP_200_OK) async def check_update( request: UpdateCheckRequest, db: AsyncSession = Depends(get_db) ): """ 检查应用更新 Args: request: 包含当前版本、平台信息的请求 Returns: 最新的版本信息,如果已是最新版本则返回 204 No Content """ # 查询最新版本 result = await db.execute( select(AppRelease) .order_by(AppRelease.release_date.desc()) .limit(1) ) latest_release = result.scalar_one_or_none() if not latest_release: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No releases found" ) # 如果已是最新版本 if latest_release.version == request.current_version: raise HTTPException( status_code=status.HTTP_204_NO_CONTENT, detail="Already up to date" ) # 确定架构(如果未提供) arch = request.architecture or _get_default_architecture(request.platform) # 查询对应平台的包 result = await db.execute( select(ReleasePackage).where( and_( ReleasePackage.release_id == latest_release.id, ReleasePackage.platform == request.platform, ReleasePackage.architecture == arch ) ) ) packages = result.scalars().all() if not packages: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No package found for platform {request.platform} {arch}" ) # 构建响应 return UpdateInfoResponse( version=latest_release.version, release_date=latest_release.release_date, notes=latest_release.notes, mandatory=latest_release.mandatory, packages=[ PackageInfo( platform=p.platform, architecture=p.architecture, file_url=p.file_url, file_size=p.file_size, file_hash=p.file_hash ) for p in packages ] ) @router.get("/download/{version}/{platform}", response_model=DownloadResponse) async def get_download_url( version: str, platform: str, db: AsyncSession = Depends(get_db) ): """ 获取下载 URL 并记录下载日志 Args: version: 目标版本 platform: 平台类型 Returns: 包含下载 URL、文件大小和哈希的响应 """ # 查询版本信息 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="Release not found" ) # 查询对应平台的包 result = await db.execute( select(ReleasePackage).where( and_( ReleasePackage.release_id == release.id, ReleasePackage.platform == platform ) ) ) package = result.scalar_one_or_none() if not package: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Package not found for platform {platform}" ) # 增加下载计数 package.download_count += 1 # 记录下载日志(可选,不阻塞主流程) download_log = UpdateDownload( release_id=release.id, platform=platform, app_version=version ) db.add(download_log) await db.commit() return DownloadResponse( download_url=package.file_url, file_size=package.file_size, file_hash=package.file_hash ) @router.post("/releases", response_model=ReleaseResponse) async def create_release( release: ReleaseCreate, db: AsyncSession = Depends(get_db) ): """ 创建新版本发布 Args: release: 版本发布信息 Returns: 创建的版本发布信息 """ # 检查版本是否已存在 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="Version already exists" ) # 创建发布记录 new_release = AppRelease( version=release.version, release_date=datetime.utcnow(), notes=release.notes, mandatory=release.mandatory ) db.add(new_release) await db.flush() # 获取 ID # 创建包记录 for pkg in release.packages: package = ReleasePackage( release_id=new_release.id, platform=pkg.platform, architecture=pkg.architecture, filename=pkg.file_url.split("/")[-1], file_url=pkg.file_url, file_size=pkg.file_size, file_hash=pkg.file_hash ) db.add(package) await db.commit() # 构建响应 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=pkg.platform, architecture=pkg.architecture, file_url=pkg.file_url, file_size=pkg.file_size, file_hash=pkg.file_hash ) for pkg in release.packages ] ) @router.get("/releases", response_model=list[ReleaseResponse]) async def list_releases( db: AsyncSession = Depends(get_db) ): """ 获取所有版本发布列表 Returns: 所有版本发布信息列表 """ result = await db.execute( select(AppRelease).order_by(AppRelease.release_date.desc()) ) releases = result.scalars().all() responses = [] for release in releases: # 获取包信息 result = await db.execute( select(ReleasePackage).where( ReleasePackage.release_id == release.id ) ) packages = result.scalars().all() responses.append(ReleaseResponse( id=release.id, version=release.version, release_date=release.release_date, notes=release.notes, mandatory=release.mandatory, created_at=release.created_at, packages=[ PackageInfo( platform=p.platform, architecture=p.architecture, file_url=p.file_url, file_size=p.file_size, file_hash=p.file_hash ) for p in packages ] )) return responses @router.delete("/releases/{version}") async def delete_release( version: str, db: AsyncSession = Depends(get_db) ): """ 删除版本发布 Args: version: 要删除的版本号 Returns: 操作结果 """ 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="Release not found" ) await db.delete(release) await db.commit() return {"status": "success", "message": f"Release {version} deleted"} # 辅助函数 def _get_default_architecture(platform: str) -> str: """获取系统架构默认值""" if platform == "darwin": return "universal" # macOS 默认 universal elif platform == "linux": return "amd64" # Linux 默认 amd64 else: return "x86_64" # Windows 默认 x86_64 ``` ### 3.4 注册路由 在 `python-api/app/main.py` 中注册: ```python from app.api.v1.update import router as update_router app.include_router(update_router, prefix="/api/v1/update", tags=["更新"]) ``` --- ## 四、Rust 后端更新逻辑 ### 4.1 模块结构 ``` tauri-app/src-tauri/src/ ├── updater.rs # 更新模块主文件 ├── lib.rs # 命令注册 └── Cargo.toml # 依赖配置 ``` ### 4.2 依赖配置 在 `tauri-app/src-tauri/Cargo.toml` 中添加: ```toml [dependencies] # 现有依赖... sha2 = "0.10" # SHA256 哈希计算 tokio = { version = "1", features = ["fs", "io-util"] } # 异步 I/O dirs = "5" # 获取系统目录路径 ``` ### 4.3 更新模块实现 文件位置:`tauri-app/src-tauri/src/updater.rs` ```rust use tauri::{AppHandle, Emitter}; use reqwest::{self, Client}; use std::fs::{self, File}; use std::io::Write; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; use sha2::{Sha256, Digest}; // ============ 数据结构 ============ #[derive(Debug, Serialize, Deserialize)] pub struct PackageInfo { pub platform: String, pub architecture: String, pub file_url: String, pub file_size: u64, pub file_hash: String, } #[derive(Debug, Serialize, Deserialize)] pub struct UpdateInfo { pub version: String, pub release_date: String, pub notes: String, pub mandatory: bool, pub packages: Vec, } #[derive(Debug, Serialize, Deserialize)] pub struct DownloadProgress { pub downloaded: u64, pub total: u64, pub percentage: f32, } // ============ Tauri 命令 ============ /// 检查应用更新 #[tauri::command] pub async fn check_update( app: AppHandle, current_version: String, ) -> Result, String> { // 获取平台信息 let platform = std::env::consts::OS; let platform_name = match platform { "macos" => "darwin", "windows" => "windows", "linux" => "linux", _ => return Err(format!("Unsupported platform: {}", platform)), }; // 获取架构信息 let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "arm64" } else { return Err("Unsupported architecture".to_string()); }; // 调用 FastAPI 检查更新 let client = Client::new(); let api_url = "http://localhost:8080"; // 从配置读取 let url = format!( "{}/api/v1/update/check", api_url ); let response = client .post(&url) .json(&serde_json::json!({ "current_version": current_version, "platform": platform_name, "architecture": arch })) .send() .await .map_err(|e| format!("Failed to check update: {}", e))?; if response.status() == 204 { return Ok(None); // 已是最新版本 } let update_info: UpdateInfo = response .json() .await .map_err(|e| format!("Failed to parse update info: {}", e))?; Ok(Some(update_info)) } /// 下载更新包 #[tauri::command] pub async fn download_update( app: AppHandle, url: String, file_hash: String, expected_size: u64, ) -> Result { // 创建更新目录 let cache_dir = app.path().app_cache_dir() .map_err(|e| format!("Failed to get cache dir: {}", e))?; let updates_dir = cache_dir.join("updates"); fs::create_dir_all(&updates_dir) .map_err(|e| format!("Failed to create updates dir: {}", e))?; // 从 URL 提取文件名 let filename = url.split('/').last() .unwrap_or("update.pkg") .to_string(); let save_path = updates_dir.join(&filename); // 检查是否已下载并验证 if save_path.exists() { if verify_file_hash(&save_path, &file_hash).await { return Ok(save_path.to_string_lossy().to_string()); } // 哈希不匹配,删除重新下载 fs::remove_file(&save_path)?; } // 发起下载请求 let client = Client::new(); let mut response = client .get(&url) .send() .await .map_err(|e| format!("Failed to download: {}", e))?; let total_size = expected_size; let mut downloaded = 0u64; let mut file = File::create(&save_path) .map_err(|e| format!("Failed to create file: {}", e))?; // 流式下载并报告进度 while let Some(chunk_result) = response.chunk().await { let chunk = chunk_result .map_err(|e| format!("Download error: {}", e))?; file.write_all(&chunk) .map_err(|e| format!("Write error: {}", e))?; downloaded += chunk.len() as u64; // 发送进度事件 let percentage = if total_size > 0 { (downloaded as f32 / total_size as f32) * 100.0 } else { 0.0 }; let _ = app.emit("download-progress", DownloadProgress { downloaded, total: total_size, percentage, }); } // 验证文件哈希 if !verify_file_hash(&save_path, &file_hash).await { fs::remove_file(&save_path)?; return Err("File hash verification failed".to_string()); } Ok(save_path.to_string_lossy().to_string()) } /// 安装更新包 #[tauri::command] pub async fn install_update( app: AppHandle, package_path: String, ) -> Result<(), String> { let package_path = PathBuf::from(package_path); #[cfg(target_os = "macos")] { install_macos(app, package_path).await?; } #[cfg(target_os = "windows")] { install_windows(app, package_path).await?; } #[cfg(target_os = "linux")] { install_linux(app, package_path).await?; } Ok(()) } // ============ 平台特定实现 ============ #[cfg(target_os = "macos")] async fn install_macos(app: AppHandle, package_path: PathBuf) -> Result<(), String> { use std::process::Command; use std::thread; use std::time::Duration; // macOS: 使用 hdiutil 挂载 dmg 并复制应用到 ~/Applications let mount_dir = PathBuf::from("/tmp/meijiaka_mount"); // 确保挂载目录不存在 if mount_dir.exists() { let _ = Command::new("hdiutil") .args(["detach", mount_dir.to_str().unwrap(), "-force"]) .status(); } // 挂载 DMG let output = Command::new("hdiutil") .args([ "attach", "-readonly", "-nobrowse", "-mountpoint", mount_dir.to_str().unwrap(), package_path.to_str().unwrap(), ]) .output() .map_err(|e| format!("Failed to mount DMG: {}", e))?; if !output.status.success() { return Err(format!("Failed to mount DMG: {}", String::from_utf8_loss(output.stderr.as_slice()))); } // 查找应用文件 let app_path = find_app_in_dmg(&mount_dir)?; let app_name = app_path.file_name() .ok_or("Invalid app path")? .to_string_lossy() .to_string(); // 用户目录 Applications let dest_dir = dirs::home_dir() .ok_or("Failed to get home directory")? .join("Applications"); fs::create_dir_all(&dest_dir) .map_err(|e| format!("Failed to create Applications dir: {}", e))?; let dest_path = dest_dir.join(&app_name); // 删除旧版本 if dest_path.exists() { let _ = Command::new("rm") .args(["-rf", dest_path.to_str().unwrap()]) .status(); } // 复制应用 let output = Command::new("cp") .args(["-R", app_path.to_str().unwrap(), dest_dir.to_str().unwrap()]) .output() .map_err(|e| format!("Failed to copy app: {}", e))?; if !output.status.success() { return Err("Failed to copy app".to_string()); } // 卸载 DMG let _ = Command::new("hdiutil") .args(["detach", mount_dir.to_str().unwrap(), "-force"]) .status(); // 延迟启动新版本 thread::spawn(move || { thread::sleep(Duration::from_secs(2)); let _ = Command::new("open") .args(["-a", &app_name]) .status(); }); // 退出当前应用 app.exit(0); Ok(()) } #[cfg(target_os = "macos")] fn find_app_in_dmg(mount_dir: &PathBuf) -> Result { use std::fs; let entries = fs::read_dir(mount_dir) .map_err(|e| format!("Failed to read mount dir: {}", e))?; for entry in entries { let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; let path = entry.path(); // 查找 .app 目录 if path.extension().and_then(|s| s.to_str()) == Some("app") { return Ok(path); } // 递归查找 if path.is_dir() { if let Ok(app_path) = find_app_in_dmg(&path) { return Ok(app_path); } } } Err("App not found in DMG".to_string()) } #[cfg(target_os = "windows")] async fn install_windows(app: AppHandle, package_path: PathBuf) -> Result<(), String> { use std::process::Command; use std::thread; use std::time::Duration; // Windows: 使用 NSIS 安装包 let _ = Command::new(package_path.to_str().unwrap()) .args(["/S"]) // 静默安装 .spawn() .map_err(|e| format!("Failed to start installer: {}", e))?; // 延迟退出,让安装程序完成 thread::sleep(Duration::from_secs(2)); app.exit(0); Ok(()) } #[cfg(target_os = "linux")] async fn install_linux(app: AppHandle, package_path: PathBuf) -> Result<(), String> { use std::process::Command; use std::thread; use std::time::Duration; // Linux: 提取 AppImage 并设置执行权限 let home_dir = dirs::home_dir() .ok_or("Failed to get home directory")?; let app_dir = home_dir.join("Applications"); fs::create_dir_all(&app_dir)?; let filename = package_path.file_name() .ok_or("Invalid package path")?; let dest_path = app_dir.join(filename); // 复制文件 fs::copy(&package_path, &dest_path)?; // 设置执行权限 #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&dest_path)?.permissions(); perms.set_mode(perms.mode() | 0o111); fs::set_permissions(&dest_path, perms)?; } // 启动新版本 thread::spawn(move || { thread::sleep(Duration::from_secs(2)); let _ = Command::new(dest_path.to_str().unwrap()) .status(); }); app.exit(0); Ok(()) } // ============ 工具函数 ============ /// 验证文件 SHA256 哈希 async fn verify_file_hash(file_path: &PathBuf, expected_hash: &str) -> bool { use tokio::fs; let file = fs::File::open(file_path).await; if file.is_err() { return false; } let mut file = file.unwrap(); let mut hasher = Sha256::new(); let mut buffer = [0u8; 8192]; loop { let n = file.read(&mut buffer).await.unwrap_or(0); if n == 0 { break; } hasher.update(&buffer[..n]); } let actual_hash = format!("sha256:{:x}", hasher.finalize()); actual_hash == expected_hash } ``` **注意**:上述示例代码混合使用了同步和异步 I/O。在生产环境中,建议统一使用异步 I/O 以获得更好的性能。统一后的代码需要使用 `tokio::fs` 替代 `std::fs`,并使用 `tokio::process::Command` 替代 `std::process::Command`。 ### 4.4 注册命令 在 `tauri-app/src-tauri/src/lib.rs` 中注册命令: ```rust mod updater; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ // ... 其他命令 updater::check_update, updater::download_update, updater::install_update ]) .setup(|app| { #[cfg(debug_assertions)] { let window = app.get_webview_window("main").unwrap(); window.open_devtools(); } Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` --- ## 五、前端实现 ### 5.1 更新状态管理 文件位置:`tauri-app/src/store/updateStore.ts` ```typescript import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; interface UpdatePackage { platform: string; architecture: string; file_url: string; file_size: number; file_hash: string; } interface UpdateInfo { version: string; release_date: string; notes: string; mandatory: boolean; packages: UpdatePackage[]; } interface DownloadProgress { downloaded: number; total: number; percentage: number; } interface UpdateState { updateAvailable: boolean; updateInfo: UpdateInfo | null; downloadProgress: DownloadProgress; downloading: boolean; checking: boolean; installing: boolean; downloadedPath: string | null; checkUpdate: (currentVersion: string) => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; dismissUpdate: () => void; } export const useUpdateStore = create((set, get) => ({ updateAvailable: false, updateInfo: null, downloadProgress: { downloaded: 0, total: 0, percentage: 0 }, downloading: false, checking: false, installing: false, downloadedPath: null, checkUpdate: async (currentVersion: string) => { set({ checking: true }); try { const updateInfo = await invoke('check_update', { currentVersion, }); if (updateInfo) { set({ updateAvailable: true, updateInfo }); } else { set({ updateAvailable: false, updateInfo: null }); } } catch (error) { console.error('Check update failed:', error); set({ updateAvailable: false, updateInfo: null }); } finally { set({ checking: false }); } }, downloadUpdate: async () => { const { updateInfo } = get(); if (!updateInfo) { throw new Error('No update available'); } // 获取当前平台的包 const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux'; const arch = process.arch === 'arm64' ? 'arm64' : 'x86_64'; const pkg = updateInfo.packages.find( p => p.platform === platform && p.architecture === arch ); if (!pkg) { throw new Error(`No package found for ${platform} ${arch}`); } set({ downloading: true, downloadProgress: { downloaded: 0, total: pkg.file_size, percentage: 0 } }); try { // 监听下载进度 const unlisten = await listen('download-progress', (event) => { set({ downloadProgress: event.payload }); }); const downloadedPath = await invoke('download_update', { url: pkg.file_url, fileHash: pkg.file_hash, expectedSize: pkg.file_size, }); await unlisten(); set({ downloadedPath }); } catch (error) { console.error('Download failed:', error); throw error; } finally { set({ downloading: false }); } }, installUpdate: async () => { const { downloadedPath } = get(); if (!downloadedPath) { throw new Error('No downloaded package'); } set({ installing: true }); try { await invoke('install_update', { packagePath: downloadedPath, }); // 安装后应用会重启,这里不会执行 } catch (error) { console.error('Install failed:', error); throw error; } finally { set({ installing: false }); } }, dismissUpdate: () => { set({ updateAvailable: false, updateInfo: null }); }, })); ``` ### 5.2 更新对话框组件 文件位置:`tauri-app/src/components/UpdateDialog.tsx` ```tsx import { useEffect } from 'react'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { useUpdateStore } from '@/store/updateStore'; export function UpdateDialog() { const { updateAvailable, updateInfo, downloadProgress, downloading, checking, installing, checkUpdate, downloadUpdate, installUpdate, dismissUpdate, } = useUpdateStore(); // 当前应用版本(从 package.json 或构建时注入) const CURRENT_VERSION = import.meta.env.VITE_APP_VERSION || '0.1.0'; useEffect(() => { // 启动时检查更新 checkUpdate(CURRENT_VERSION); }, []); if (!updateAvailable) return null; const isMandatory = updateInfo?.mandatory; return ( !open && !isMandatory && dismissUpdate()}> {checking ? '检查更新...' : downloading ? '下载更新中...' : installing ? '安装更新...' : `发现新版本 ${updateInfo?.version}`}
{!checking && updateInfo && ( <>
{updateInfo.notes}
{downloading && (
{Math.round(downloadProgress.percentage)}% ({(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} MB / {(downloadProgress.total / 1024 / 1024).toFixed(1)} MB)
)}
{!downloading && !installing && ( <> {!isMandatory && ( )} )} {!checking && !downloading && installing && ( )} {downloading && ( )}
{isMandatory && !installing && !downloading && (
此版本为强制更新,必须安装后才能继续使用
)} )}
); } ``` ### 5.3 设置页面集成 在设置页面添加手动检查更新按钮: ```tsx import { useUpdateStore } from '@/store/updateStore'; import { Button } from '@/components/ui/button'; function SettingsPage() { const { checkUpdate, updateAvailable, updateInfo } = useUpdateStore(); const CURRENT_VERSION = import.meta.env.VITE_APP_VERSION || '0.1.0'; return (

应用更新

当前版本:{CURRENT_VERSION}

{updateAvailable && updateInfo && (

发现新版本 {updateInfo.version}

{updateInfo.notes}

)}
); } ``` --- ## 六、七牛云集成 ### 6.1 七牛云配置 在 `python-api/.env` 中添加: ```bash # 七牛云配置 QINIU_ACCESS_KEY=your-access-key QINIU_SECRET_KEY=your-secret-key QINIU_BUCKET_NAME=meijiaka-releases QINIU_BUCKET_DOMAIN=cdn.meijiaka.com ``` ### 6.2 七牛云服务封装 文件位置:`python-api/app/services/qiniu_service.py` ```python import os import hashlib from typing import Optional from qiniu import Auth, BucketManager class QiniuService: """七牛云服务封装""" def __init__(self): access_key = os.getenv('QINIU_ACCESS_KEY') secret_key = os.getenv('QINIU_SECRET_KEY') self.bucket_name = os.getenv('QINIU_BUCKET_NAME') self.domain = os.getenv('QINIU_BUCKET_DOMAIN') self.auth = Auth(access_key, secret_key) self.bucket = BucketManager(self.auth) def get_file_url(self, key: str) -> str: """获取文件访问 URL""" return f"https://{self.domain}/{key}" def upload_file(self, local_path: str, key: str) -> dict: """上传文件到七牛云""" from qiniu import put_file_v2, etag token = self.auth.upload_token(self.bucket_name, key, 3600) ret, info = put_file_v2(token, key, local_path, version='v2') if ret is None: raise Exception(f"上传失败: {info}") return { "key": ret['key'], "hash": ret['hash'], "url": self.get_file_url(key) } def get_file_info(self, key: str) -> dict: """获取文件信息""" ret, info = self.bucket.stat(self.bucket_name, key) if ret is None: raise Exception(f"获取文件信息失败: {info}") return ret def get_file_hash(self, local_path: str) -> str: """计算本地文件 SHA256 哈希""" sha256_hash = hashlib.sha256() with open(local_path, "rb") as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return f"sha256:{sha256_hash.hexdigest()}" # 全局单例 _qiniu_service: Optional[QiniuService] = None def get_qiniu_service() -> QiniuService: global _qiniu_service if _qiniu_service is None: _qiniu_service = QiniuService() return _qiniu_service ``` --- ## 七、部署流程 ### 7.1 构建流程 ```bash # 1. 构建 Tauri 应用 cd tauri-app npm run tauri build # 2. 构建产物位置 # macOS: src-tauri/target/release/bundle/dmg/ # Windows: src-tauri/target/release/bundle/nsis/ # Linux: src-tauri/target/release/bundle/appimage/ ``` ### 7.2 上传到七牛云 文件位置:`python-api/scripts/upload_release.py` ```python #!/usr/bin/env python3 """ 上传版本发布包到七牛云并创建版本发布记录 """ import os import sys import argparse import subprocess from pathlib import Path # 添加项目路径 sys.path.insert(0, str(Path(__file__).parent.parent)) from app.services.qiniu_service import get_qiniu_service import httpx def upload_package(local_path: str, version: str) -> dict: """上传单个安装包""" qiniu = get_qiniu_service() filename = Path(local_path).name key = f"releases/{version}/{filename}" # 计算哈希 file_hash = qiniu.get_file_hash(local_path) # 上传 print(f"上传 {filename}...") result = qiniu.upload_file(local_path, key) # 获取文件信息 file_info = qiniu.get_file_info(key) return { "platform": filename.split('_')[2], "architecture": filename.split('_')[3].split('.')[0], "file_url": result['url'], "file_size": file_info['fsize'], "file_hash": file_hash } def create_release(version: str, notes: str, packages: list, mandatory: bool = False): """创建版本发布""" api_url = "http://localhost:8080/api/v1/update/releases" response = httpx.post(api_url, json={ "version": version, "notes": notes, "mandatory": mandatory, "packages": packages }) if response.status_code == 200: print(f"版本 {version} 发布成功!") return response.json() else: print(f"创建版本发布失败: {response.text}") sys.exit(1) def main(): parser = argparse.ArgumentParser(description="上传版本发布包") parser.add_argument("--version", required=True, help="版本号 (如: 0.1.1)") parser.add_argument("--notes", required=True, help="更新说明") parser.add_argument("--mandatory", action="store_true", help="强制更新") parser.add_argument("--path", required=True, help="构建产物目录") args = parser.parse_args() build_dir = Path(args.path) # 查找所有安装包 packages = [] for file in build_dir.rglob("*"): if file.suffix in ['.dmg', '.exe', '.AppImage']: pkg_info = upload_package(str(file), args.version) packages.append(pkg_info) # 创建版本发布 create_release(args.version, args.notes, packages, args.mandatory) if __name__ == "__main__": main() ``` 使用方式: ```bash cd python-api python scripts/upload_release.py \ --version 0.1.1 \ --notes "新功能:视频字幕压制\n修复:导出问题" \ --path ../tauri-app/src-tauri/target/release/bundle ``` ### 7.3 GitHub Actions 自动化(多平台) 文件位置:`.github/workflows/release.yml` ```yaml name: Release on: push: tags: - 'v*' jobs: # macOS 构建 build-macos: runs-on: macos-latest steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install Rust uses: dtolnay/rust-toolchain@stable with: target: x86_64-apple-darwin - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" - name: Install Python dependencies run: | cd python-api python -m venv venv source venv/bin/activate pip install -e ".[dev]" pip install qiniu httpx - name: Install Node dependencies run: | cd tauri-app npm install - name: Build Tauri app (macOS) run: | cd tauri-app npm run tauri build --target x86_64-apple-darwin - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: macos-bundle path: tauri-app/src-tauri/target/x86_64-apple-darwin/release/bundle/ retention-days: 1 # Windows 构建 build-windows: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install Rust uses: dtolnay/rust-toolchain@stable with: target: x86_64-pc-windows-msvc - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" - name: Install Python dependencies run: | cd python-api python -m venv venv venv\Scripts\pip install -e ".[dev]" venv\Scripts\pip install qiniu httpx - name: Install Node dependencies run: | cd tauri-app npm install - name: Build Tauri app (Windows) ) run: | cd tauri-app npm run tauri build --target x86_64-pc-windows-msvc - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: windows-bundle path: tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/ retention-days: 1 # Linux 构建 build-linux: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install Rust uses: dtolnay/rust-toolchain@stable with: target: x86_64-unknown-linux-gnu - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" - name: Install Python dependencies run: | cd python-api python -m venv venv source venv/bin/activate pip install -e ".[dev]" pip install qiniu httpx - name: Install Node dependencies run: | cd tauri-app npm install - name: Install Linux dependencies run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev - name: Build Tauri app (Linux) run: | cd tauri-app npm run tauri build --target x86_64-unknown-linux-gnu - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: linux-bundle path: tauri-app/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/ retention-days: 1 # 统一发布 release: needs: [build-macos, build-windows, build-linux] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.13' - name: Download macOS artifacts uses: actions/download-artifact@v4 with: name: macos-bundle path: bundle/macos - name: Download Windows artifacts uses: actions/download-artifact@v4 with: name: windows-bundle path: bundle/windows - name: Download Linux artifacts uses: actions/download-artifact@v4 with: name: linux-bundle path: bundle/linux - name: Install Python dependencies run: | cd python-api python -m venv venv source venv/bin/activate pip install -e ".[dev]" pip install qiniu httpx - name: Upload to Qiniu and create release env: QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }} QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }} QINIU_BUCKET_NAME: ${{ secrets.QINIU_BUCKET_NAME }} QINIU_BUCKET_DOMAIN: ${{ secrets.QINIU_BUCKET_DOMAIN }} run: | VERSION=${GITHUB_REF#refs/tags/v} cd python-api source venv/bin/activate # 上传所有平台的包 python scripts/upload_release.py \ --version $VERSION \ --notes "Release $VERSION" \ --path ../bundle ``` --- ## 八、配置总结 ### 8.1 功能配置 | 配置项 | 选择 | |--------|------| | OSS/COS 服务 | 七牛云 | | 更新触发方式 | 启动时自动检查,需用户确认再下载 | | 灰度发布 | 不需要 | | 强制更新 | 需要 | | 版本兼容性检查 | 不需要 | | 更新检查频率 | 仅启动时 + 手动按钮 | | 下载日志统计 | 需要 | | macOS 安装位置 | ~/Applications(无需管理员) | | 更新 UI 风格 | shadcn/ui 对话框 | | 构建部署自动化 | 需要 | ### 8.2 环境变量 **后端 (.env)**: ```bash # 七牛云配置 QINIU_ACCESS_KEY=your-access-key QINIU_SECRET_KEY=your-secret-key QINIU_BUCKET_NAME=meijiaka-releases QINIU_BUCKET_DOMAIN=cdn.meijiaka.com # FastAPI 配置 DATABASE_URL=postgresql+asyncpg://... API_BASE_URL=http://localhost:8080 ``` **前端**: ```bash # 应用版本(在构建时注入) VITE_APP_VERSION=0.1.0 ``` ### 8.3 文件清单 ``` python-api/ ├── app/ │ ├── models/ │ │ └── update.py # 数据模型 │ ├── schemas/ │ │ └── update.py # Pydantic schemas │ ├── api/ │ │ └── v1/ │ │ └── update.py # API 路由 │ ├── services/ │ │ └── qiniu_service.py # 七牛云服务 │ └── main.py # 注册路由 ├── scripts/ │ ├── init_update_tables.sql # 数据库初始化 │ └── upload_release.py # 上传和发布脚本 └── .env # 环境变量 tauri-app/ ├── src/ │ ├── components/ │ │ └── UpdateDialog.tsx # 更新对话框 │ ├── store/ │ │ └── updateStore.ts # 更新状态管理 │ └── pages/ │ └── Settings.tsx # 设置页面集成 └── src-tauri/ ├── src/ │ ├── updater.rs # Rust 更新模块 │ └── lib.rs # 命令注册 └── Cargo.toml # 依赖配置 .github/ └── workflows/ └── release.yml # 自动化发布 ``` --- ## 九、测试指南 ### 9.1 本地测试 1. **初始化数据库**: ```bash cd python-api psql -h localhost -U postgres -d meijiaka -f scripts/init_update_tables.sql ``` 2. **创建测试版本**: ```bash curl -X POST http://localhost:8080/api/v1/update/releases \ -H "Content-Type: application/json" \ -d '{ "version": "0.1.1", "notes": "测试版本", "mandatory": false, "packages": [ { "platform": "darwin", "architecture": "x86_64", "file_url": "https://cdn.meijiaka.com/releases/test.dmg", "file_size": 102400000, "file_hash": "sha256:test123" } ] }' ``` 3. **启动 Tauri 应用并测试更新流程** ### 9.2 测试检查清单 - [ ] 版本检查 API 返回正确信息 - [ ] 下载进度正确显示 - [ ] 文件哈希验证工作正常 - [ ] macOS 安装到 ~/Applications - [ ] 安装后自动重启应用 - [ ] 强制更新无法跳过 - [ ] 下载日志正确记录 - [ ] 手动检查更新按钮工作 --- ## 十、常见问题 ### Q1: 如何处理更新失败? **A**: Rust 后端会验证文件哈希,如果验证失败会删除损坏的文件并提示用户重试。建议实现重试机制,最多重试 3 次。 ### Q2: 如何回滚版本? **A**: 1. 发布上一个版本为最新 2. 或标记当前版本为不可用(修改 mandatory 和 notes) ```sql -- 回滚到上一个版本 UPDATE app_releases SET release_date = NOW() WHERE version = '0.1.0'; ``` ### Q3: 如何处理网络中断? **A**: Rust 下载器使用流式下载,支持断点续传。建议实现下载状态持久化,应用重启后继续下载。 ### Q4: 如何测试更新流程? **A**: 1. 修改前端当前版本为旧版本 2. 创建测试版本发布 3. 启动应用测试更新流程 4. 使用七牛云测试文件(不是真实安装包) ### Q5: macOS 安装需要权限吗? **A**: 使用 ~/Applications 目录,用户权限即可,不需要管理员权限。 --- ## 十一、错误处理与重试机制 ### 11.1 下载重试机制 在 Rust 下载函数中添加重试逻辑: ```rust use std::time::Duration; #[tauri::command] pub async fn download_update_with_retry( app: AppHandle, url: String, file_hash: String, expected_size: u64, max_retries: u32, ) -> Result { let mut last_error = String::new(); for attempt in 0..max_retries { match download_update_internal(&app, &url, &file_hash, expected_size).await { Ok(path) => return Ok(path), Err(e) => { last_error = e.clone(); eprintln!("下载失败(尝试 {}/{}):{}", attempt + 1, max_retries, e); if attempt < max_retries - 1 { // 指数退避 let delay = Duration::from_secs(2_u64.pow(attempt)); tokio::time::sleep(delay).await; } } } } Err(format!("下载失败,已重试 {} 次:{}", max_retries, last_error)) } async fn download_update_internal( app: &AppHandle, url: &str, file_hash: &str, expected_size: u64, ) -> Result { // 原有的下载逻辑 // ... } ``` ### 11.2 安装失败回滚 macOS 安装失败时自动回滚: ```rust #[cfg(target_os = "macos")] async fn install_macos(app: AppHandle, package_path: PathBuf) -> Result<(), String> { use std::process::Command; use std::thread; use std::time::Duration; let mount_dir = PathBuf::from("/tmp/meijiaka_mount"); let backup_path = PathBuf::from("/tmp/meijiaka_backup"); // 1. 挂载 DMG if mount_dir.exists() { let _ = Command::new("hdiutil") .args(["detach", mount_dir.to_str().unwrap(), "-force"]) .status(); } let output = Command::new("hdiutil") .args([ "attach", "-readonly", "-nobrowse", "-mountpoint", mount_dir.to_str().unwrap(), package_path.to_str().unwrap(), ]) .output() .map_err(|e| format!("Failed to mount DMG: {}", e))?; if !output.status.success() { return Err(format!("Failed to mount DMG: {}", String::from_utf8_lossy(output.stderr.as_slice()))); } // 2. 查找应用文件 let app_path = find_app_in_dmg(&mount_dir)?; let app_name = app_path.file_name() .ok_or("Invalid app path")? .to_string_lossy() .to_string(); let dest_dir = dirs::home_dir() .ok_or("Failed to get home directory")? .join("Applications"); fs::create_dir_all(&dest_dir) .map_err(|e| format!("Failed to create Applications dir: {}", e))?; let dest_path = dest_dir.join(&app_name); // 3. 备份旧版本 let backup_exists = dest_path.exists(); if backup_exists { fs::create_dir_all(&backup_path)?; let _ = Command::new("cp") .args(["-R", dest_path.to_str().unwrap(), backup_path.to_str().unwrap()]) .status(); } // 4. 删除旧版本 if dest_path.exists() { let _ = Command::new("rm") .args(["-rf", dest_path.to_str().unwrap()]) .status(); } // 5. 复制新版本 let output = Command::new("cp") .args(["-R", app_path.to_str().unwrap(), dest_dir.to_str().unwrap()]) .output() .map_err(|e| format!("Failed to copy app: {}", e))?; if !output.status.success() { // 复制失败,恢复备份 if backup_exists { eprintln!("安装失败,正在恢复旧版本..."); let _ = Command::new("cp") .args(["-R", backup_path.join(&app_name).to_str().unwrap(), dest_dir.to_str().unwrap()]) .status(); let _ = Command::new("rm") .args(["-rf", backup_path.to_str().unwrap()]) .status(); } return Err("Failed to copy app".to_string()); } // 6. 验证新版本 if !dest_path.exists() { // 验证失败,恢复备份 if backup_exists { eprintln!("验证失败,正在恢复旧版本..."); let _ = Command::new("cp") .args(["-R", backup_path.join(&app_name).to_str().unwrap(), dest_dir.to_str().unwrap()]) .status(); } return Err("New app not found after installation".to_string()); } // 7. 清理备份 if backup_exists { let _ = Command::new("rm") .args(["-rf", backup_path.to_str().unwrap()]) .status(); } // 8. 卸载 DMG let _ = Command::new("hdiutil") .args(["detach", mount_dir.to_str().unwrap(), "-force"]) .status(); // 9. 延迟启动新版本 thread::spawn(move || { thread::sleep(Duration::from_secs(2)); let _ = Command::new("open") .args(["-a", &app_name]) .status(); }); // 10. 退出当前应用 app.exit(0); Ok(()) } ``` ### 11.3 下载状态持久化 实现下载状态保存,应用重启后继续下载: ```rust use serde::{Deserialize, Serialize}; use std::fs; #[derive(Debug, Serialize, Deserialize)] struct DownloadState { url: String, file_hash: String, expected_size: u64, downloaded: u64, save_path: String, } fn save_download_state(app: &AppHandle, state: &DownloadState) -> Result<(), String> { let cache_dir = app.path().app_cache_dir()?; let state_file = cache_dir.join("updates/download_state.json"); let json = serde_json::to_string_pretty(state) .map_err(|e| format!("Failed to serialize state: {}", e))?; fs::write(&state_file, json) .map_err(|e| format!("Failed to save state: {}", e)) } fn load_download_state(app: &AppHandle) -> Result, String> { let cache_dir = app.path().app_cache_dir()?; let state_file = cache_dir.join("updates/download_state.json"); if !state_file.exists() { return Ok(None); } let content = fs::read_to_string(&state_file) .map_err(|e| format!("Failed to read state: {}", e))?; let state: DownloadState = serde_json::from_str(&content) .map_err(|e| format!("Failed to parse state: {}", e))?; Ok(Some(state)) } ``` --- ## 十二、安全性最佳实践 ### 12.1 强制 HTTPS 在 FastAPI 中强制使用 HTTPS: ```python from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware # 在生产环境强制 HTTPS if not os.getenv("DEBUG"): app.add_middleware(HTTPSRedirectMiddleware) ``` ### 12.2 API 速率限制 使用 slowapi 实现速率限制: ```python from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded # 配置速率限制 limiter = Limiter( key_func=get_remote_address, default_limits=["200/minute"], storage_uri="redis://localhost:6379/1" ) app.state.limiter = limiter # 在检查更新接口添加速率限制 @router.post("/check", response_model=UpdateInfoResponse) @limiter.limit("10/minute") # 每分钟最多 10 次检查 async def check_update( request: UpdateCheckRequest, db: AsyncSession = Depends(get_db) ): # ... ``` ### 12.3 文件签名验证 添加 HMAC 签名验证: ```python import hmac import hashlib from fastapi import Header, HTTPException def verify_file_signature( file_path: str, expected_signature: str, secret: str ) -> bool: """验证文件签名""" with open(file_path, 'rb') as f: file_hash = hashlib.sha256(f.read()).hexdigest() signature = hmac.new( secret.encode(), file_hash.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected_signature) ``` ### 12.4 CORS 配置 严格配置 CORS: ```python from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=[ "tauri://localhost", # Tauri 开发环境 "http://localhost:1420", # Vite 开发服务器 "https://yourdomain.com", # 生产域名 ], allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["*"], ) ``` ### 12.5 环境变量验证 在启动时验证必需的环境变量: ```python import os def validate_env_vars(): """验证环境变量""" required_vars = [ "DATABASE_URL", "QINIU_ACCESS_KEY", "QINIU_SECRET_KEY", "QINIU_BUCKET_NAME", "QINIU_BUCKET_DOMAIN", ] missing = [var for var in required_vars if not os.getenv(var)] if missing: raise RuntimeError(f"缺少必需的环境变量: {', '.join(missing)}") # 在应用启动时调用 validate_env_vars() ``` ### 12.6 敏感信息不记录 确保日志中不包含敏感信息: ```python import logging # 配置日志过滤器 class SensitiveDataFilter(logging.Filter): """过滤敏感数据""" SENSITIVE_KEYWORDS = ["access_key", "secret_key", "password", "token"] def filter(self, record): msg = record.getMessage().lower() if any(keyword in msg for keyword in self.SENSITIVE_KEYWORDS): return False return True # 添加过滤器 logging.getLogger().addFilter(SensitiveDataFilter()) ``` --- ## 十三、参考资料 - [Tauri 更新插件文档](https://v2.tauri.app/plugin/updater/) - [七牛云 Python SDK](https://developer.qiniu.com/kodo/1242/python) - [语义化版本规范](https://semver.org/lang/zh-CN/) - [FastAPI 官方文档](https://fastapi.tiangolo.com/zh/) - [FastAPI 安全最佳实践](https://fastapi.tiangolo.com/tutorial/security/)