Files
aiagent/backend/app/api/app_update.py
renjianbo fee1df2c2f feat: add PC admin App Version Management page with APK upload
- 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>
2026-07-01 22:37:19 +08:00

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)