Files
server-core/app/api/routers/rbac_desktop_updates.py
T
Pine 4dedb43d7f 算力多级折扣与企业余额体系 - 服务端核心改造
数据模型:
- 新增 tenant_discounts(运营端载体折扣)
- 新增 tenant_user_discounts(载体端账号折扣)
- 新增 compute_recharges(算力充值记录)
- 新增 compute_balance_allocations(余额分配记录)
- 新增 compute_usage_records(算力使用记录)
- 扩展 park_tenants(default_company_discount, default_user_discount)
- 扩展 park_companies(compute_balance, compute_balance_used)
- 扩展 company_members(role, compute_balance, compute_balance_used, left_at, left_reason)

核心服务:
- compute_pricing_service.py:四级折扣价格计算、余额扣费、企业余额管理

API接口(/api/compute):
- 运营端:载体折扣管理、标准价管理
- 载体端:企业折扣设置、账号折扣设置、默认折扣设置
- 企业端:余额充值、成员余额分配/回收、充值/分配记录
- 个人端:我的余额、使用记录、退出企业
- 内部:价格查询、算力扣费

数据库迁移:0064_compute_discount_balance.py
2026-09-06 17:59:56 +08:00

509 lines
19 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""桌面端(PineAgents)版本管理与更新源。
两类端点:
- 管理端点(/admin/desktop-versions*):运营方上传平台安装包 + minisign 签名、
设置发布说明 / 灰度比例 / 强制最低版本、发布 / 下架。
- 公开端点(/desktop-updates/*):桌面端 Tauri updater 拉取 latest.json(按
X-App-Version 灰度)、meta.json(强制更新判定)与安装包下载。
签名约定:构建机用 minisign 私钥为安装包生成 .sigTauri 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 = 500 * 1024 * 1024 # 500MB
# ---------------------------------------------------------------------------
# 请求/响应模型
# ---------------------------------------------------------------------------
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.dev2post/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 匹配,返回规范化签名文本。"""
if "untrusted comment:" not in sig_text:
raise HTTPException(status_code=400, detail="签名文件不是合法的 minisign 文本")
if config.DESKTOP_UPDATER_PUBKEY:
try:
sig_key = _minisign_key_id(sig_text)
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 sig_text.strip()
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 _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 标准 manifestversion/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}/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-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)