# -*- 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) class DesktopPresignRequest(BaseModel): """OSS 预签名直传请求:前端先拿临时 PUT URL,直传 OSS 后再 confirm 登记。""" target: str = Field(..., description="平台 target,如 darwin-aarch64") filename: str = Field(..., description="原始文件名(用于拼 OSS key)") content_type: str = Field("application/octet-stream", description="Content-Type") expires_in: int = Field(600, ge=60, le=3600, description="预签名 URL 有效期(秒)") class DesktopArtifactConfirmRequest(BaseModel): """更新包直传完成后确认登记。""" target: str key: str = Field(..., description="OSS 对象 key(presign 返回的 key)") filename: str sha256: str size: int = Field(..., ge=0) signature_text: str = Field("", description=".sig 文件内容(明文 minisign 或 Tauri v2 base64)") class DesktopInstallerConfirmRequest(BaseModel): """标准安装包直传完成后确认登记(无签名)。""" target: str key: str filename: str sha256: str size: int = Field(..., ge=0) # --------------------------------------------------------------------------- # 工具 # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- # OSS 预签名直传(前端直传 OSS,绕过后端带宽;confirm 时登记元信息) # --------------------------------------------------------------------------- def _validate_oss_key(key: str, expected_prefix: str) -> str: """校验 OSS key:非空、无目录穿越、前缀匹配。""" key = (key or "").strip().strip("/") if not key or ".." in key.split("/"): raise HTTPException(status_code=400, detail=f"非法 OSS key: {key!r}") if not key.startswith(expected_prefix): raise HTTPException(status_code=400, detail=f"key 前缀应为 {expected_prefix}/") return key @router.post("/desktop-versions/{vid}/artifacts/presign", summary="生成更新包 OSS 预签名直传链接") async def presign_desktop_artifact( vid: str, payload: DesktopPresignRequest, db: Database = Depends(get_db), actor: dict = Depends(require_permission("menu:admin_desktop_updates")), ): if payload.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="已下架版本不可再上传产物") if not oss.enabled: raise HTTPException(status_code=503, detail="OSS 未配置,无法生成直传链接;请使用服务端中转上传") fname = Path(payload.filename).name or f"artifact-{secrets.token_hex(4)}" key = f"desktop-updates/{version['version']}/{payload.target}/{fname}" upload_url = await oss.presigned_put( key, expires=payload.expires_in, content_type=payload.content_type ) await write_audit( db, action="desktop_version.artifact_presign", resource="desktop_version", resource_id=vid, detail=f"version={version['version']} target={payload.target} key={key}", user=actor, ) return {"upload_url": upload_url, "key": key, "method": "PUT", "expires_in": payload.expires_in} @router.post("/desktop-versions/{vid}/artifacts/confirm", summary="确认更新包直传完成并登记") async def confirm_desktop_artifact( vid: str, payload: DesktopArtifactConfirmRequest, db: Database = Depends(get_db), actor: dict = Depends(require_permission("menu:admin_desktop_updates")), ): if payload.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="已下架版本不可再登记产物") key = _validate_oss_key(payload.key, "desktop-updates/") if payload.size > MAX_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail=f"安装包超过 {MAX_ARTIFACT_BYTES // 1024 // 1024 // 1024}GB 限制") signature = "" if payload.signature_text: signature = _verify_signature(payload.signature_text) artifacts = dict(version.get("artifacts") or {}) artifacts[payload.target] = { "filename": Path(payload.filename).name, "url": f"/desktop-updates/files/{key}", "signature": signature, "sha256": payload.sha256, "size": payload.size, } updated = await db.desktop_versions.update( vid, {"artifacts_json": json.dumps(artifacts, ensure_ascii=False)} ) await write_audit( db, action="desktop_version.artifact_confirm", resource="desktop_version", resource_id=vid, detail=f"version={version['version']} target={payload.target} {payload.size}B sha256={payload.sha256[:12]}", user=actor, ) return updated @router.post("/desktop-versions/{vid}/installers/presign", summary="生成标准安装包 OSS 预签名直传链接") async def presign_desktop_installer( vid: str, payload: DesktopPresignRequest, db: Database = Depends(get_db), actor: dict = Depends(require_permission("menu:admin_desktop_updates")), ): if payload.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="已下架版本不可再上传安装包") if not oss.enabled: raise HTTPException(status_code=503, detail="OSS 未配置,无法生成直传链接;请使用服务端中转上传") fname = Path(payload.filename).name or f"installer-{secrets.token_hex(4)}" expect_ext = _installer_extension(payload.target) if not fname.lower().endswith(expect_ext.lower()): raise HTTPException( status_code=400, detail=f"target={payload.target} 的安装包扩展名应为 {expect_ext}(当前: {Path(fname).suffix or '(无)'})", ) key = f"desktop-installers/{version['version']}/{payload.target}/{fname}" upload_url = await oss.presigned_put( key, expires=payload.expires_in, content_type=payload.content_type ) await write_audit( db, action="desktop_version.installer_presign", resource="desktop_version", resource_id=vid, detail=f"version={version['version']} target={payload.target} key={key}", user=actor, ) return {"upload_url": upload_url, "key": key, "method": "PUT", "expires_in": payload.expires_in} @router.post("/desktop-versions/{vid}/installers/confirm", summary="确认标准安装包直传完成并登记") async def confirm_desktop_installer( vid: str, payload: DesktopInstallerConfirmRequest, db: Database = Depends(get_db), actor: dict = Depends(require_permission("menu:admin_desktop_updates")), ): if payload.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="已下架版本不可再登记安装包") key = _validate_oss_key(payload.key, "desktop-installers/") if payload.size > MAX_ARTIFACT_BYTES: raise HTTPException(status_code=413, detail=f"安装包超过 {MAX_ARTIFACT_BYTES // 1024 // 1024 // 1024}GB 限制") installers = dict(version.get("installers") or {}) installers[payload.target] = { "filename": Path(payload.filename).name, "url": f"/desktop-installers/files/{key}", "sha256": payload.sha256, "size": payload.size, } updated = await db.desktop_versions.update( vid, {"installers_json": json.dumps(installers, ensure_ascii=False)} ) await write_audit( db, action="desktop_version.installer_confirm", resource="desktop_version", resource_id=vid, detail=f"version={version['version']} target={payload.target} {payload.size}B sha256={payload.sha256[:12]}", 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)