Files
meijiaka-zy/docs/app-update-system.md
T

66 KiB
Raw Blame History

应用自动更新系统开发文档

概述

本文档详细说明美家卡智影应用自动更新系统的完整实现方案,采用自建更新服务器 + 七牛云存储的架构,解决国内访问 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()

示例数据:

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)

示例数据:

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 索引设计

-- 快速查询最新版本
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

-- 创建版本发布记录表
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

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

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

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 中注册:

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 中添加:

[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

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<PackageInfo>,
}

#[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<Option<UpdateInfo>, 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<String, String> {
    // 创建更新目录
    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<PathBuf, String> {
    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 中注册命令:

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

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<void>;
  downloadUpdate: () => Promise<void>;
  installUpdate: () => Promise<void>;
  dismissUpdate: () => void;
}

export const useUpdateStore = create<UpdateState>((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<UpdateInfo | null>('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<DownloadProgress>('download-progress', (event) => {
        set({ downloadProgress: event.payload });
      });

      const downloadedPath = await invoke<string>('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

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 (
    <Dialog open={updateAvailable} onOpenChange={(open) => !open && !isMandatory && dismissUpdate()}>
      <DialogContent className="sm:max-w-md">
        <DialogTitle className="text-xl font-bold">
          {checking ? '检查更新...' :
           downloading ? '下载更新中...' :
           installing ? '安装更新...' :
           `发现新版本 ${updateInfo?.version}`}
        </DialogTitle>

        <div className="space-y-4">
          {!checking && updateInfo && (
            <>
              <div className="text-sm text-muted-foreground whitespace-pre-line">
                {updateInfo.notes}
              </div>

              {downloading && (
                <div className="space-y-2">
                  <Progress value={downloadProgress.percentage} />
                  <div className="text-xs text-muted-foreground text-center">
                    {Math.round(downloadProgress.percentage)}%
                    ({(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} MB /
                     {(downloadProgress.total / 1024 / 1024).toFixed(1)} MB)
                  </div>
                </div>
              )}

              <div className="flex justify-end gap-2">
                {!downloading && !installing && (
                  <>
                    {!isMandatory && (
                      <Button variant="outline" onClick={dismissUpdate}>
                        稍后
                      </Button>
                    )}
                    <Button onClick={downloadUpdate}>
                      立即更新
                    </Button>
                  </>
                )}

                {!checking && !downloading && installing && (
                  <Button onClick={installUpdate} disabled>
                    安装中...
                  </Button>
                )}

                {downloading && (
                  <Button variant="outline" disabled>
                    取消
                  </Button>
                )}
              </div>

              {isMandatory && !installing && !downloading && (
                <div className="text-sm text-orange-500 text-center">
                  此版本为强制更新,必须安装后才能继续使用
                </div>
              )}
            </>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}

5.3 设置页面集成

在设置页面添加手动检查更新按钮:

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 (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h3 className="text-lg font-semibold">应用更新</h3>
          <p className="text-sm text-muted-foreground">
            当前版本:{CURRENT_VERSION}
          </p>
        </div>
        <Button onClick={() => checkUpdate(CURRENT_VERSION)}>
          检查更新
        </Button>
      </div>

      {updateAvailable && updateInfo && (
        <div className="rounded-lg border p-4">
          <h4 className="font-semibold">发现新版本 {updateInfo.version}</h4>
          <p className="text-sm text-muted-foreground mt-2">
            {updateInfo.notes}
          </p>
        </div>
      )}
    </div>
  );
}

六、七牛云集成

6.1 七牛云配置

python-api/.env 中添加:

# 七牛云配置
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

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 构建流程

# 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

#!/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()

使用方式:

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

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):

# 七牛云配置
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

前端:

# 应用版本(在构建时注入)
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. 初始化数据库
cd python-api
psql -h localhost -U postgres -d meijiaka -f scripts/init_update_tables.sql
  1. 创建测试版本
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"
      }
    ]
  }'
  1. 启动 Tauri 应用并测试更新流程

9.2 测试检查清单

  • 版本检查 API 返回正确信息
  • 下载进度正确显示
  • 文件哈希验证工作正常
  • macOS 安装到 ~/Applications
  • 安装后自动重启应用
  • 强制更新无法跳过
  • 下载日志正确记录
  • 手动检查更新按钮工作

十、常见问题

Q1: 如何处理更新失败?

A: Rust 后端会验证文件哈希,如果验证失败会删除损坏的文件并提示用户重试。建议实现重试机制,最多重试 3 次。

Q2: 如何回滚版本?

A:

  1. 发布上一个版本为最新
  2. 或标记当前版本为不可用(修改 mandatory 和 notes
-- 回滚到上一个版本
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 下载函数中添加重试逻辑:

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<String, String> {
    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<String, String> {
    // 原有的下载逻辑
    // ...
}

11.2 安装失败回滚

macOS 安装失败时自动回滚:

#[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 下载状态持久化

实现下载状态保存,应用重启后继续下载:

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<Option<DownloadState>, 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

from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware

# 在生产环境强制 HTTPS
if not os.getenv("DEBUG"):
    app.add_middleware(HTTPSRedirectMiddleware)

12.2 API 速率限制

使用 slowapi 实现速率限制:

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 签名验证:

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

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 环境变量验证

在启动时验证必需的环境变量:

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 敏感信息不记录

确保日志中不包含敏感信息:

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())

十三、参考资料