653 lines
25 KiB
Python
653 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""桌面端(PineAgents)版本管理与更新源。
|
||
|
||
两类端点:
|
||
- 管理端点(/admin/desktop-versions*):运营方上传平台安装包 + minisign 签名、
|
||
设置发布说明 / 灰度比例 / 强制最低版本、发布 / 下架。
|
||
- 公开端点(/desktop-updates/*):桌面端 Tauri updater 拉取 latest.json(按
|
||
X-App-Version 灰度)、meta.json(强制更新判定)与安装包下载。
|
||
|
||
签名约定:构建机用 minisign 私钥为安装包生成 .sig(Tauri build 配置
|
||
TAURI_SIGNING_PRIVATE_KEY 自动产出);上传时若配置了 DESKTOP_UPDATER_PUBKEY,
|
||
则校验签名 key id 与公钥一致,防止混入非本机签名的产物。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import binascii
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import secrets
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||
from fastapi.responses import FileResponse, RedirectResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from ... import config
|
||
from ...infrastructure.oss import oss
|
||
from ...infrastructure.repositories import Database, utcnow_iso
|
||
from ...rbac import require_permission, require_roles, write_audit
|
||
from ..dependencies import get_db
|
||
|
||
router = APIRouter(prefix="/admin", tags=["admin-desktop"])
|
||
public_router = APIRouter(tags=["desktop-updates"])
|
||
|
||
# Tauri updater 平台 target(与桌面端 bundle 产物一一对应)
|
||
DESKTOP_TARGETS = (
|
||
"darwin-aarch64",
|
||
"darwin-x86_64",
|
||
"windows-x86_64",
|
||
"linux-x86_64",
|
||
)
|
||
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+([-+][0-9A-Za-z.\-]+)?$")
|
||
MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024 # 2GB(桌面端含 PyInstaller 后端,体积较大)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 请求/响应模型
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DesktopVersionCreate(BaseModel):
|
||
version: str = Field(..., description="semver,如 0.3.0")
|
||
channel: str = Field("stable", description="stable | beta")
|
||
notes: str = Field("", description="发布说明(Markdown)")
|
||
min_version: str = Field("", description="强制升级最低版本,空=不强制")
|
||
|
||
|
||
class DesktopVersionUpdate(BaseModel):
|
||
channel: str | None = None
|
||
notes: str | None = None
|
||
min_version: str | None = None
|
||
|
||
|
||
class DesktopPublishRequest(BaseModel):
|
||
rollout: int | None = Field(None, ge=0, le=100, description="灰度比例;缺省/100=全量")
|
||
|
||
|
||
class DesktopRolloutRequest(BaseModel):
|
||
rollout: int = Field(..., ge=0, le=100)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _normalize_version(version: str) -> str:
|
||
"""把多种写法统一为标准 semver。
|
||
|
||
支持:
|
||
- 标准 semver: 0.0.1 / 0.0.1-beta.1 / 0.0.1+build.1
|
||
- PEP 440: 0.0.1b1 / 0.0.1rc1 / 0.0.1a1 / 0.0.1.post1 / 0.0.1.dev2
|
||
- 点号预发布: 0.0.1.beta.1 / 0.0.1.alpha.1 / 0.0.1.rc.1
|
||
"""
|
||
v = version.strip()
|
||
# 已是 semver(含 - 或 + 预发布/元数据,或纯三段)
|
||
if VERSION_RE.match(v):
|
||
return v
|
||
# PEP 440: 0.0.1b1 / 0.0.1rc1 / 0.0.1a1(可叠加 .postN / .devN)
|
||
m = re.match(
|
||
r"^(\d+\.\d+\.\d+)(a|b|rc)(\d+)(?:\.post(\d+))?(?:\.dev(\d+))?$", v
|
||
)
|
||
if m:
|
||
base, pre, n, post, dev = m.groups()
|
||
pre_map = {"a": "alpha", "b": "beta", "rc": "rc"}
|
||
labels = [f"{pre_map[pre]}.{n}"]
|
||
if dev:
|
||
labels.append(f"dev.{dev}")
|
||
suffix = "-" + ".".join(labels)
|
||
meta = f"+post.{post}" if post else ""
|
||
return f"{base}{suffix}{meta}"
|
||
# PEP 440: 0.0.1.post1 / 0.0.1.dev2(post/dev 单独,无预发布标签)
|
||
m = re.match(r"^(\d+\.\d+\.\d+)(?:\.post(\d+))?(?:\.dev(\d+))?$", v)
|
||
if m and (m.group(2) or m.group(3)):
|
||
base, post, dev = m.groups()
|
||
labels = []
|
||
if dev:
|
||
labels.append(f"dev.{dev}")
|
||
suffix = "-" + ".".join(labels) if labels else ""
|
||
meta = f"+post.{post}" if post else ""
|
||
return f"{base}{suffix}{meta}"
|
||
# 点号预发布: 0.0.1.beta.1 / 0.0.1.alpha.1 / 0.0.1.rc.1
|
||
m = re.match(r"^(\d+\.\d+\.\d+)\.(alpha|beta|rc|dev)\.(\d+)$", v)
|
||
if m:
|
||
base, label, n = m.groups()
|
||
return f"{base}-{label}.{n}"
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"版本号不合法(支持 semver 0.0.1-beta.1 / PEP 440 0.0.1b1 / 点号 0.0.1.beta.1): {version}",
|
||
)
|
||
|
||
|
||
def _validate_version(version: str) -> str:
|
||
"""规范化并校验版本号,返回标准 semver。"""
|
||
return _normalize_version(version)
|
||
|
||
|
||
def _version_key(version: str) -> tuple:
|
||
parts: list[int] = [0, 0, 0]
|
||
for i, seg in enumerate(str(version).lstrip("v").split("-", 1)[0].split(".")[:3]):
|
||
try:
|
||
parts[i] = int(seg)
|
||
except ValueError:
|
||
break
|
||
return tuple(parts)
|
||
|
||
|
||
def _minisign_key_id(text: str) -> str:
|
||
"""从 minisign 文本块提取 key id(与 scripts/pack-tauri/generate_update_manifest.py 同算法)。"""
|
||
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
|
||
raw = base64.b64decode(lines[1], validate=True)
|
||
if len(raw) < 10:
|
||
raise ValueError("minisign 数据过短")
|
||
return raw[2:10].hex()
|
||
|
||
|
||
def _pubkey_key_id(pubkey_b64: str) -> str:
|
||
"""tauri.conf.json 里的 pubkey 是 base64 串:先解码成 minisign 文本,再取 key id。"""
|
||
text = base64.b64decode(pubkey_b64, validate=True).decode("utf-8")
|
||
return _minisign_key_id(text)
|
||
|
||
|
||
def _verify_signature(sig_text: str) -> str:
|
||
"""校验 .sig 文本格式;配置了公钥时校验 key id 匹配,返回规范化签名文本。
|
||
|
||
支持两种格式:
|
||
1. 明文 minisign 格式(含 "untrusted comment:" 行)
|
||
2. Tauri v2 格式(单行 base64,解码后为明文 minisign 格式)
|
||
"""
|
||
normalized = sig_text.strip()
|
||
# Tauri v2 生成的 .sig 是单行 base64,需先解码
|
||
if "untrusted comment:" not in normalized:
|
||
try:
|
||
decoded = base64.b64decode(normalized, validate=True).decode("utf-8")
|
||
if "untrusted comment:" in decoded:
|
||
normalized = decoded.strip()
|
||
except (binascii.Error, UnicodeDecodeError):
|
||
pass
|
||
if "untrusted comment:" not in normalized:
|
||
raise HTTPException(status_code=400, detail="签名文件不是合法的 minisign 文本")
|
||
if config.DESKTOP_UPDATER_PUBKEY:
|
||
try:
|
||
sig_key = _minisign_key_id(normalized)
|
||
pub_key = _pubkey_key_id(config.DESKTOP_UPDATER_PUBKEY)
|
||
except (ValueError, IndexError, binascii.Error, UnicodeDecodeError) as exc:
|
||
raise HTTPException(status_code=400, detail=f"签名/公钥解析失败: {exc}") from exc
|
||
if sig_key != pub_key:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"签名 key id 与配置公钥不匹配(sig={sig_key} pub={pub_key}),请使用本机私钥重新签名",
|
||
)
|
||
return normalized
|
||
|
||
|
||
def _target_extension(target: str) -> str:
|
||
return {
|
||
"darwin-aarch64": ".tar.gz",
|
||
"darwin-x86_64": ".tar.gz",
|
||
"windows-x86_64": ".exe",
|
||
"linux-x86_64": ".AppImage",
|
||
}[target]
|
||
|
||
|
||
def _installer_extension(target: str) -> str:
|
||
"""website 下载页标准安装包扩展名(macOS=.dmg / Windows=.exe / Linux=.AppImage)。"""
|
||
return {
|
||
"darwin-aarch64": ".dmg",
|
||
"darwin-x86_64": ".dmg",
|
||
"windows-x86_64": ".exe",
|
||
"linux-x86_64": ".AppImage",
|
||
}[target]
|
||
|
||
|
||
def _base_url(request: Request) -> str:
|
||
"""对外绝对地址前缀:优先 PUBLIC_BASE_URL,否则回退请求 Host。"""
|
||
if config.PUBLIC_BASE_URL:
|
||
return config.PUBLIC_BASE_URL
|
||
return str(request.base_url).rstrip("/")
|
||
|
||
|
||
def _client_version(request: Request) -> str | None:
|
||
value = request.headers.get("X-App-Version", "").strip()
|
||
return value or None
|
||
|
||
|
||
def _build_manifest(version: dict, base_url: str) -> dict:
|
||
"""组装 Tauri updater 标准 manifest(version/notes/pub_date/platforms)。"""
|
||
platforms: dict[str, dict] = {}
|
||
for target, art in (version.get("artifacts") or {}).items():
|
||
url = (art or {}).get("url", "")
|
||
signature = (art or {}).get("signature", "")
|
||
if not url or not signature:
|
||
continue
|
||
platforms[target] = {
|
||
"url": f"{base_url}{url}",
|
||
"signature": signature,
|
||
}
|
||
return {
|
||
"version": version["version"],
|
||
"notes": version.get("notes", "") or "",
|
||
"pub_date": version.get("pub_date") or utcnow_iso(),
|
||
"platforms": platforms,
|
||
}
|
||
|
||
|
||
def _select_latest(
|
||
candidates: list[dict],
|
||
client_version: str | None,
|
||
channel: str = "stable",
|
||
) -> dict | None:
|
||
"""按版本从高到低选择发布候选;灰度未命中时退回上一全量版本。"""
|
||
stable = [v for v in candidates if (v.get("channel") or "stable") == channel]
|
||
if not stable:
|
||
stable = candidates
|
||
latest = stable[0] if stable else None
|
||
if latest is None:
|
||
return None
|
||
if (
|
||
latest.get("status") == "rolling"
|
||
and int(latest.get("rollout") or 0) < 100
|
||
and client_version
|
||
):
|
||
hit = int(hashlib.sha256(client_version.encode()).hexdigest(), 16) % 100
|
||
if hit >= int(latest.get("rollout") or 0):
|
||
for v in stable[1:]:
|
||
if v.get("status") == "published" and v.get("artifacts"):
|
||
return v
|
||
return None
|
||
return latest
|
||
|
||
|
||
async def _get_version(db: Database, vid: str) -> dict:
|
||
version = await db.desktop_versions.get(vid)
|
||
if version is None:
|
||
raise HTTPException(status_code=404, detail="版本不存在")
|
||
return version
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 管理端点(运营方 operator)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/desktop-versions", summary="桌面端版本列表")
|
||
async def list_desktop_versions(
|
||
db: Database = Depends(get_db),
|
||
_role: dict = Depends(require_roles("operator")),
|
||
_perm: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
return await db.desktop_versions.list()
|
||
|
||
|
||
@router.get("/desktop-versions/{vid}", summary="桌面端版本详情")
|
||
async def get_desktop_version(
|
||
vid: str,
|
||
db: Database = Depends(get_db),
|
||
_role: dict = Depends(require_roles("operator")),
|
||
_perm: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
return await _get_version(db, vid)
|
||
|
||
|
||
@router.post("/desktop-versions", summary="新建桌面端版本(草稿)")
|
||
async def create_desktop_version(
|
||
req: DesktopVersionCreate,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
version = _validate_version(req.version)
|
||
min_version = _validate_version(req.min_version) if req.min_version and req.min_version.strip() else ""
|
||
if await db.desktop_versions.get_by_version(version):
|
||
raise HTTPException(status_code=409, detail=f"版本 {version} 已存在")
|
||
created = await db.desktop_versions.create(
|
||
version=version,
|
||
channel=(req.channel or "stable").strip() or "stable",
|
||
notes=req.notes or "",
|
||
min_version=min_version,
|
||
created_by=(actor.get("username") or actor.get("id") or ""),
|
||
)
|
||
await write_audit(
|
||
db, action="desktop_version.create", resource="desktop_version",
|
||
resource_id=created["id"], detail=f"version={version}", user=actor, request=request,
|
||
)
|
||
return created
|
||
|
||
|
||
@router.patch("/desktop-versions/{vid}", summary="更新版本元信息(说明/频道/最低版本)")
|
||
async def update_desktop_version(
|
||
vid: str,
|
||
req: DesktopVersionUpdate,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
version = await _get_version(db, vid)
|
||
fields: dict = {}
|
||
if req.channel is not None:
|
||
fields["channel"] = req.channel.strip() or "stable"
|
||
if req.notes is not None:
|
||
fields["notes"] = req.notes
|
||
if req.min_version is not None:
|
||
mv = req.min_version.strip()
|
||
fields["min_version"] = _validate_version(mv) if mv else ""
|
||
updated = await db.desktop_versions.update(vid, fields)
|
||
await write_audit(
|
||
db, action="desktop_version.update", resource="desktop_version",
|
||
resource_id=vid, detail=f"version={version['version']}", user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.post("/desktop-versions/{vid}/artifacts", summary="上传平台安装包 + minisign 签名")
|
||
async def upload_desktop_artifact(
|
||
vid: str,
|
||
request: Request,
|
||
target: str = Form(...),
|
||
file: UploadFile = File(...),
|
||
sig: UploadFile | None = File(None),
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
if target not in DESKTOP_TARGETS:
|
||
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
|
||
version = await _get_version(db, vid)
|
||
if version["status"] == "archived":
|
||
raise HTTPException(status_code=400, detail="已下架版本不可再上传产物")
|
||
|
||
data = await file.read()
|
||
if not data:
|
||
raise HTTPException(status_code=400, detail="空文件")
|
||
if len(data) > MAX_ARTIFACT_BYTES:
|
||
raise HTTPException(status_code=413, detail="安装包超过 500MB")
|
||
|
||
signature = ""
|
||
if sig is not None:
|
||
sig_data = await sig.read()
|
||
if not sig_data:
|
||
raise HTTPException(status_code=400, detail="签名文件为空")
|
||
try:
|
||
signature = _verify_signature(sig_data.decode("utf-8", errors="replace"))
|
||
except UnicodeDecodeError as exc:
|
||
raise HTTPException(status_code=400, detail="签名文件不是文本格式") from exc
|
||
|
||
fname = (file.filename or f"artifact-{secrets.token_hex(4)}").rsplit("/", 1)[-1]
|
||
fname = Path(fname).name or f"artifact-{secrets.token_hex(4)}"
|
||
key = f"desktop-updates/{version['version']}/{target}/{fname}"
|
||
path = await oss.upload(key, data, content_type="application/octet-stream")
|
||
|
||
sha256 = hashlib.sha256(data).hexdigest()
|
||
artifacts = dict(version.get("artifacts") or {})
|
||
artifacts[target] = {
|
||
"filename": fname,
|
||
"url": f"/desktop-updates/files/{key}",
|
||
"signature": signature,
|
||
"sha256": sha256,
|
||
"size": len(data),
|
||
}
|
||
updated = await db.desktop_versions.update(vid, {"artifacts_json": json.dumps(artifacts, ensure_ascii=False)})
|
||
await write_audit(
|
||
db, action="desktop_version.upload", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']} target={target} {len(data)}B sha256={sha256[:12]}",
|
||
user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.post("/desktop-versions/{vid}/installers", summary="上传标准安装包(website 下载页用:macOS=.dmg / Windows=.exe)")
|
||
async def upload_desktop_installer(
|
||
vid: str,
|
||
request: Request,
|
||
target: str = Form(...),
|
||
file: UploadFile = File(...),
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
if target not in DESKTOP_TARGETS:
|
||
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
|
||
version = await _get_version(db, vid)
|
||
if version["status"] == "archived":
|
||
raise HTTPException(status_code=400, detail="已下架版本不可再上传安装包")
|
||
|
||
data = await file.read()
|
||
if not data:
|
||
raise HTTPException(status_code=400, detail="空文件")
|
||
if len(data) > MAX_ARTIFACT_BYTES:
|
||
raise HTTPException(status_code=413, detail="安装包超过大小限制")
|
||
|
||
fname = (file.filename or f"installer-{secrets.token_hex(4)}").rsplit("/", 1)[-1]
|
||
fname = Path(fname).name or f"installer-{secrets.token_hex(4)}"
|
||
expect_ext = _installer_extension(target)
|
||
if not fname.lower().endswith(expect_ext.lower()):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"target={target} 的安装包扩展名应为 {expect_ext}(当前: {Path(fname).suffix or '(无)'})",
|
||
)
|
||
|
||
key = f"desktop-installers/{version['version']}/{target}/{fname}"
|
||
path = await oss.upload(key, data, content_type="application/octet-stream")
|
||
|
||
sha256 = hashlib.sha256(data).hexdigest()
|
||
installers = dict(version.get("installers") or {})
|
||
installers[target] = {
|
||
"filename": fname,
|
||
"url": f"/desktop-installers/files/{key}",
|
||
"sha256": sha256,
|
||
"size": len(data),
|
||
}
|
||
updated = await db.desktop_versions.update(
|
||
vid, {"installers_json": json.dumps(installers, ensure_ascii=False)}
|
||
)
|
||
await write_audit(
|
||
db, action="desktop_version.installer_upload", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']} target={target} {fname} {len(data)}B sha256={sha256[:12]}",
|
||
user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.delete("/desktop-versions/{vid}/installers/{target}", summary="删除标准安装包")
|
||
async def delete_desktop_installer(
|
||
vid: str,
|
||
target: str,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
if target not in DESKTOP_TARGETS:
|
||
raise HTTPException(status_code=400, detail=f"target 仅支持 {', '.join(DESKTOP_TARGETS)}")
|
||
version = await _get_version(db, vid)
|
||
installers = dict(version.get("installers") or {})
|
||
if target not in installers:
|
||
raise HTTPException(status_code=404, detail="该平台安装包不存在")
|
||
del installers[target]
|
||
updated = await db.desktop_versions.update(
|
||
vid, {"installers_json": json.dumps(installers, ensure_ascii=False)}
|
||
)
|
||
await write_audit(
|
||
db, action="desktop_version.installer_delete", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']} target={target}",
|
||
user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.post("/desktop-versions/{vid}/publish", summary="发布(全量或按灰度比例)")
|
||
async def publish_desktop_version(
|
||
vid: str,
|
||
req: DesktopPublishRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
version = await _get_version(db, vid)
|
||
if not version.get("artifacts"):
|
||
raise HTTPException(status_code=400, detail="至少上传一个平台安装包后才能发布")
|
||
rollout = req.rollout
|
||
status = "published"
|
||
if rollout is not None and rollout < 100:
|
||
status = "rolling"
|
||
else:
|
||
rollout = 100
|
||
updated = await db.desktop_versions.update(
|
||
vid,
|
||
{"status": status, "rollout": int(rollout), "pub_date": utcnow_iso()},
|
||
)
|
||
await write_audit(
|
||
db, action="desktop_version.publish", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']} status={status} rollout={rollout}",
|
||
user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.post("/desktop-versions/{vid}/rollout", summary="调整灰度比例(100=全量,<100 回到灰度)")
|
||
async def set_desktop_rollout(
|
||
vid: str,
|
||
req: DesktopRolloutRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
version = await _get_version(db, vid)
|
||
status = "published" if req.rollout >= 100 else "rolling"
|
||
updated = await db.desktop_versions.update(
|
||
vid, {"status": status, "rollout": req.rollout},
|
||
)
|
||
await write_audit(
|
||
db, action="desktop_version.rollout", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']} rollout={req.rollout} status={status}",
|
||
user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.post("/desktop-versions/{vid}/archive", summary="下架版本(回滚时发布上一版本)")
|
||
async def archive_desktop_version(
|
||
vid: str,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
version = await _get_version(db, vid)
|
||
if version["status"] == "archived":
|
||
raise HTTPException(status_code=400, detail="版本已下架")
|
||
updated = await db.desktop_versions.update(vid, {"status": "archived"})
|
||
await write_audit(
|
||
db, action="desktop_version.archive", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']}", user=actor, request=request,
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.delete("/desktop-versions/{vid}", summary="删除版本(仅草稿)")
|
||
async def delete_desktop_version(
|
||
vid: str,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_permission("menu:admin_desktop_updates")),
|
||
):
|
||
version = await _get_version(db, vid)
|
||
if version["status"] != "draft":
|
||
raise HTTPException(status_code=400, detail="仅草稿版本可删除(已发布请先下架)")
|
||
await db.desktop_versions.delete(vid)
|
||
await write_audit(
|
||
db, action="desktop_version.delete", resource="desktop_version", resource_id=vid,
|
||
detail=f"version={version['version']}", user=actor, request=request,
|
||
)
|
||
return {"ok": True, "id": vid}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 公开端点(桌面端更新源)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@public_router.get("/desktop-updates/latest.json", summary="Tauri updater 更新清单(支持灰度)")
|
||
async def desktop_updates_latest(request: Request, db: Database = Depends(get_db)):
|
||
client_version = _client_version(request)
|
||
channel = request.headers.get("X-App-Channel", "stable").strip() or "stable"
|
||
candidates = await db.desktop_versions.publishable()
|
||
latest = _select_latest(candidates, client_version, channel)
|
||
if latest is None:
|
||
return {
|
||
"version": "0.0.0",
|
||
"notes": "",
|
||
"pub_date": utcnow_iso(),
|
||
"platforms": {},
|
||
}
|
||
return _build_manifest(latest, _base_url(request))
|
||
|
||
|
||
@public_router.get("/desktop-updates/meta.json", summary="更新元信息(强制更新判定)")
|
||
async def desktop_updates_meta(request: Request, db: Database = Depends(get_db)):
|
||
client_version = _client_version(request)
|
||
channel = request.headers.get("X-App-Channel", "stable").strip() or "stable"
|
||
candidates = await db.desktop_versions.publishable()
|
||
latest = _select_latest(candidates, client_version, channel)
|
||
if latest is None:
|
||
return {"version": "", "min_version": "", "notes": "", "channel": ""}
|
||
return {
|
||
"version": latest["version"],
|
||
"min_version": latest.get("min_version") or "",
|
||
"notes": latest.get("notes") or "",
|
||
"channel": latest.get("channel") or "",
|
||
}
|
||
|
||
|
||
@public_router.get("/desktop-installers/latest.json", summary="website 下载页标准安装包清单(macOS=.dmg / Windows=.exe)")
|
||
async def desktop_installers_latest(request: Request, db: Database = Depends(get_db)):
|
||
candidates = await db.desktop_versions.publishable()
|
||
if not candidates:
|
||
return {"version": "", "pub_date": utcnow_iso(), "platforms": {}}
|
||
latest = candidates[0]
|
||
base = _base_url(request)
|
||
platforms: dict[str, dict] = {}
|
||
for target, art in (latest.get("installers") or {}).items():
|
||
url = (art or {}).get("url", "")
|
||
if not url:
|
||
continue
|
||
platforms[target] = {
|
||
"filename": art.get("filename", ""),
|
||
"url": f"{base}{url}",
|
||
"sha256": art.get("sha256", ""),
|
||
"size": art.get("size", 0),
|
||
}
|
||
return {
|
||
"version": latest["version"],
|
||
"notes": latest.get("notes", "") or "",
|
||
"pub_date": latest.get("pub_date") or utcnow_iso(),
|
||
"platforms": platforms,
|
||
}
|
||
|
||
|
||
@public_router.get("/desktop-installers/files/{key:path}", summary="标准安装包下载(OSS 直链/本地文件)")
|
||
async def desktop_installers_file(key: str):
|
||
try:
|
||
key = oss.clean_key(key)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
if not key.startswith("desktop-installers/"):
|
||
raise HTTPException(status_code=404, detail="not found")
|
||
if oss.enabled:
|
||
return RedirectResponse(await oss.download_url(key), status_code=307)
|
||
local = oss.local_dir / key
|
||
if not local.is_file():
|
||
raise HTTPException(status_code=404, detail="安装包不存在")
|
||
return FileResponse(local)
|
||
|
||
|
||
@public_router.get("/desktop-updates/files/{key:path}", summary="安装包下载(OSS 直链/本地文件)")
|
||
async def desktop_updates_file(key: str):
|
||
try:
|
||
key = oss.clean_key(key)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
if not key.startswith("desktop-updates/"):
|
||
raise HTTPException(status_code=404, detail="not found")
|
||
if oss.enabled:
|
||
return RedirectResponse(await oss.download_url(key), status_code=307)
|
||
local = oss.local_dir / key
|
||
if not local.is_file():
|
||
raise HTTPException(status_code=404, detail="安装包不存在")
|
||
return FileResponse(local)
|