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

2452 lines
69 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 应用自动更新系统开发文档
## 概述
本文档详细说明美家卡智影应用自动更新系统的完整实现方案,采用自建更新服务器 + 七牛云存储的架构,解决国内访问 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 mjk_app_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 mjk_app_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 mjk_app_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 mjk_app_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 mjk_app_release_packages(platform, architecture);
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON mjk_app_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` | 统一下载入口:自动匹配最新版本和平台安装包 | 无需认证 |
| POST | `/api/v1/update/releases` | 创建新版本发布 | 需要管理员认证 |
| GET | `/api/v1/update/releases` | 获取所有版本列表 | 需要管理员认证 |
| DELETE | `/api/v1/update/releases/{version}` | 删除版本发布 | 需要管理员认证 |
> 说明:`/download` 优先根据 `User-Agent` 识别平台(darwin / windows / linux)和架构(x86_64 / aarch64),也支持通过查询参数显式指定,例如 `?target=darwin&arch=aarch64`。返回 302 重定向到七牛云上的对应安装包。
### 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__ = "mjk_app_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, Query, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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")
async def download_latest(
request: Request,
target: str | None = Query(None, description="平台:darwin / windows / linux"),
arch: str | None = Query(None, description="架构:x86_64 / aarch64 / i686"),
db: AsyncSession = Depends(get_db),
):
"""
统一下载入口:自动匹配最新版本和当前环境安装包。
优先级:
1. 查询参数 `target` + `arch`(最可靠,推荐应用内使用)
2. `User-Agent` 解析(兜底,适合网页/文档中的固定链接)
匹配规则:
- 返回 302 重定向到七牛云上的安装包地址
- 优先返回用户安装包(`.dmg` / `.exe` / `.msi` / `.AppImage`
- 其次返回 updater 用的 `.app.tar.gz`
- 同一平台若找不到精确架构,会兜底返回同平台的其他架构包
"""
# 1. 确定平台与架构
if target and arch:
platform = target.lower()
architecture = arch.lower()
else:
parsed = _parse_user_agent(request.headers.get("user-agent"))
if not parsed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无法识别您的操作系统,请通过官网或应用商店下载对应版本",
)
platform, architecture = parsed
# 2. 查询最新版本
result = await db.execute(
select(AppRelease).order_by(AppRelease.release_date.desc()).limit(1)
)
latest: AppRelease | None = result.scalar_one_or_none()
if not latest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="暂无可用下载",
)
# 3. 查询该平台所有包(macOS 常用 universal 包会同时写入 x86_64/aarch64
result = await db.execute(
select(ReleasePackage).where(
ReleasePackage.release_id == latest.id,
ReleasePackage.platform == platform,
)
)
platform_pkgs = list(result.scalars().all())
if not platform_pkgs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"版本 {latest.version} 暂无可用的 {platform} 安装包",
)
# 4. 优先选择用户安装包,而不是 updater 用的 .app.tar.gz
def _install_pkg_priority(pkg: ReleasePackage) -> int:
name = pkg.filename.lower()
if name.endswith(".dmg"):
return 1
if name.endswith(".exe"):
return 2
if name.endswith(".msi"):
return 3
if name.endswith(".appimage"):
return 4
if name.endswith(".app.tar.gz"):
return 10
return 5
exact_arch_pkgs = [p for p in platform_pkgs if p.architecture == architecture]
candidate_pkgs = exact_arch_pkgs or platform_pkgs
package = min(candidate_pkgs, key=_install_pkg_priority)
return RedirectResponse(url=package.file_url)
def _parse_user_agent(user_agent: str | None) -> tuple[str, str] | None:
"""从 User-Agent 解析 Tauri 平台标识和架构。"""
if not user_agent:
return None
ua = user_agent.lower()
if "windows" in ua:
platform = "windows"
arch = "aarch64" if "arm64" in ua or "aarch64" in ua else "x86_64"
return platform, arch
if "macintosh" in ua or "mac os x" in ua:
platform = "darwin"
if "arm64" in ua or "aarch64" in ua:
arch = "aarch64"
elif "intel" in ua:
arch = "x86_64"
else:
arch = "aarch64"
return platform, arch
if "linux" in ua:
platform = "linux"
arch = "aarch64" if "aarch64" in ua or "arm64" in ua else "x86_64"
return platform, arch
return None
@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<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` 中注册命令:
```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<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`
```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 设置页面集成
在设置页面添加手动检查更新按钮:
```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 (
<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` 中添加:
```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<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 安装失败时自动回滚:
```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<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
```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/)