- Backend: CRUD endpoints for app versions (list, create, delete, download) - Backend: APK file upload with multipart form data - Backend: Version data stored in JSON file, APK files in data/app_versions/ - Frontend: Vue 3 + Element Plus page at /app-versions - Frontend: Drag-and-drop APK upload, version publish, force update config - Frontend: Version history table with "set as latest" and delete actions - Menu item under System Settings (admin only) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
6.7 KiB
Python
221 lines
6.7 KiB
Python
"""
|
|
应用升级检查 & 版本管理 API
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
|
|
|
from app.core.config import settings
|
|
from app.core.security import decode_access_token
|
|
from app.core.database import get_db
|
|
from sqlalchemy.orm import Session
|
|
from app.models.user import User
|
|
|
|
router = APIRouter(
|
|
prefix="/api/v1/app",
|
|
tags=["app"],
|
|
)
|
|
|
|
# 版本数据存储路径
|
|
_BACKEND_DIR = Path(__file__).resolve().parent.parent.parent
|
|
_DATA_DIR = _BACKEND_DIR / "data" / "app_versions"
|
|
_VERSIONS_FILE = _DATA_DIR / "versions.json"
|
|
_APK_DIR = _DATA_DIR / "apks"
|
|
|
|
|
|
def _ensure_dirs():
|
|
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
_APK_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _load_versions() -> dict:
|
|
"""加载版本数据。"""
|
|
_ensure_dirs()
|
|
if not _VERSIONS_FILE.exists():
|
|
return {"versions": [], "latest": {}}
|
|
try:
|
|
with open(_VERSIONS_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {"versions": [], "latest": {}}
|
|
|
|
|
|
def _save_versions(data: dict):
|
|
"""保存版本数据。"""
|
|
_ensure_dirs()
|
|
with open(_VERSIONS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2, default=str)
|
|
|
|
|
|
def _get_latest_version_info() -> dict:
|
|
"""获取最新版本信息(供 check-update 使用)。"""
|
|
data = _load_versions()
|
|
latest = data.get("latest", {})
|
|
if not latest:
|
|
return {
|
|
"version": "0.0.0",
|
|
"url": "",
|
|
"force_update_min": "1.0.0",
|
|
"release_notes": "",
|
|
}
|
|
return latest
|
|
|
|
|
|
def _version_tuple(v: str) -> tuple:
|
|
try:
|
|
return tuple(int(x) for x in v.strip().split("."))
|
|
except (ValueError, AttributeError):
|
|
return (0,)
|
|
|
|
|
|
# ─── 客户端检查更新 ──────────────────────────────────────────
|
|
|
|
@router.get("/check-update")
|
|
async def check_update(
|
|
platform: str = Query("android", description="平台: android / ios"),
|
|
version: str = Query("1.0.0", description="当前客户端版本号"),
|
|
):
|
|
"""检查应用是否有新版本可用。"""
|
|
if platform == "android":
|
|
info = _get_latest_version_info()
|
|
latest_ver = info.get("version", "0.0.0")
|
|
url = info.get("url", "")
|
|
force_min = info.get("force_update_min", "1.0.0")
|
|
notes = info.get("release_notes", "")
|
|
else:
|
|
latest_ver = "0.0.0"
|
|
url = ""
|
|
force_min = "1.0.0"
|
|
notes = ""
|
|
|
|
current_tuple = _version_tuple(version)
|
|
latest_tuple = _version_tuple(latest_ver)
|
|
force_tuple = _version_tuple(force_min)
|
|
|
|
has_update = latest_tuple > (0,) and current_tuple < latest_tuple
|
|
force_update = current_tuple < force_tuple
|
|
|
|
return {
|
|
"latest_version": latest_ver,
|
|
"current_version": version,
|
|
"has_update": has_update,
|
|
"force_update": force_update,
|
|
"update_url": url or "",
|
|
"release_notes": notes,
|
|
}
|
|
|
|
|
|
# ─── 管理端版本列表 ──────────────────────────────────────────
|
|
|
|
async def _get_admin_user(token: str, db: Session) -> User:
|
|
"""验证管理员身份。"""
|
|
payload = decode_access_token(token)
|
|
if not payload:
|
|
raise HTTPException(status_code=401, detail="未登录")
|
|
user = db.query(User).filter(User.id == payload.get("sub")).first()
|
|
if not user or user.role != "admin":
|
|
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
|
return user
|
|
|
|
|
|
@router.get("/versions")
|
|
async def list_versions():
|
|
"""管理端:获取所有版本历史。"""
|
|
data = _load_versions()
|
|
return {
|
|
"versions": data.get("versions", []),
|
|
"latest": data.get("latest", {}),
|
|
}
|
|
|
|
|
|
@router.post("/versions")
|
|
async def create_version(
|
|
file: UploadFile = File(None),
|
|
version: str = Form(...),
|
|
platform: str = Form("android"),
|
|
force_update_min: str = Form(""),
|
|
release_notes: str = Form(""),
|
|
):
|
|
"""管理端:上传 APK 并创建新版本。"""
|
|
_ensure_dirs()
|
|
|
|
data = _load_versions()
|
|
|
|
# 保存 APK 文件
|
|
apk_url = ""
|
|
if file and file.filename:
|
|
if not file.filename.endswith(".apk"):
|
|
raise HTTPException(status_code=400, detail="仅支持 .apk 文件")
|
|
safe_name = f"{version}.apk"
|
|
file_path = _APK_DIR / safe_name
|
|
try:
|
|
with open(file_path, "wb") as f:
|
|
shutil.copyfileobj(file.file, f)
|
|
apk_url = f"/api/v1/app/download/{safe_name}"
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"文件保存失败: {e}")
|
|
|
|
# 检查版本是否已存在
|
|
versions = data.get("versions", [])
|
|
existing = next((v for v in versions if v["version"] == version and v["platform"] == platform), None)
|
|
|
|
new_entry = {
|
|
"version": version,
|
|
"platform": platform,
|
|
"url": apk_url or (existing.get("url", "") if existing else ""),
|
|
"force_update_min": force_update_min,
|
|
"release_notes": release_notes,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
if existing:
|
|
# 更新已有版本
|
|
existing.update(new_entry)
|
|
else:
|
|
versions.append(new_entry)
|
|
|
|
# 发布为最新版本
|
|
data["latest"] = new_entry
|
|
data["versions"] = sorted(versions, key=lambda v: _version_tuple(v["version"]), reverse=True)
|
|
|
|
_save_versions(data)
|
|
|
|
return {"message": f"版本 {version} 发布成功", "version": new_entry}
|
|
|
|
|
|
@router.delete("/versions/{version_str}")
|
|
async def delete_version(version_str: str):
|
|
"""管理端:删除版本记录(不删除 APK 文件)。"""
|
|
data = _load_versions()
|
|
versions = data.get("versions", [])
|
|
filtered = [v for v in versions if v["version"] != version_str]
|
|
|
|
if len(filtered) == len(versions):
|
|
raise HTTPException(status_code=404, detail="版本不存在")
|
|
|
|
data["versions"] = filtered
|
|
|
|
# 如果删除的是最新版本,重新选择
|
|
latest = data.get("latest", {})
|
|
if latest.get("version") == version_str:
|
|
data["latest"] = filtered[0] if filtered else {}
|
|
|
|
_save_versions(data)
|
|
return {"message": f"版本 {version_str} 已删除"}
|
|
|
|
|
|
@router.get("/download/{filename}")
|
|
async def download_apk(filename: str):
|
|
"""下载 APK 文件。"""
|
|
from fastapi.responses import FileResponse
|
|
|
|
file_path = _APK_DIR / filename
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="文件不存在")
|
|
return FileResponse(file_path, media_type="application/vnd.android.package-archive", filename=filename)
|