feat: 增加 Git 标签清单策略和接口
This commit is contained in:
@@ -14,6 +14,7 @@ from app.api.routes import (
|
||||
client_update,
|
||||
crash_api,
|
||||
frontend,
|
||||
git_tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,5 +32,6 @@ def include_api_routes(app: FastAPI):
|
||||
app.include_router(admin_logs.router)
|
||||
app.include_router(admin_crash_report.router)
|
||||
app.include_router(admin_device.router)
|
||||
app.include_router(git_tags.router)
|
||||
app.include_router(client_update.router)
|
||||
app.include_router(crash_api.router)
|
||||
|
||||
@@ -43,6 +43,7 @@ def admin_save_policy(body: PolicySaveRequest, auth=Depends(require_permission("
|
||||
valid_until,
|
||||
body.min_supported_version.strip(),
|
||||
json.dumps(disabled, ensure_ascii=False),
|
||||
bool(body.git_tags_enabled),
|
||||
body.message.strip(),
|
||||
)
|
||||
if result == "channel_not_found":
|
||||
|
||||
@@ -149,6 +149,7 @@ async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
|
||||
"latest_version": latest_ver,
|
||||
"min_supported_version": settings["min_supported_version"],
|
||||
"disabled_versions": settings["disabled_versions"],
|
||||
"git_tags_enabled": settings["git_tags_enabled"],
|
||||
"message": message,
|
||||
"signature_alg": "RSA-2048-SHA256",
|
||||
"key_id": SIGNING_KEY_ID,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import policy_repository
|
||||
from app.schemas.client_update import GitTagsRequest
|
||||
from app.services.common_service import policy_row_to_dict
|
||||
from app.services.git_tags_service import load_git_tags
|
||||
|
||||
|
||||
router = APIRouter(tags=["git-tags"])
|
||||
|
||||
|
||||
@router.get("/admin/git/tags")
|
||||
def admin_git_tags(
|
||||
force_refresh: bool = Query(False),
|
||||
auth=Depends(require_permission("policy:view")),
|
||||
):
|
||||
try:
|
||||
return load_git_tags(force_refresh=force_refresh)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"获取 Git 标签失败:{exc}") from exc
|
||||
|
||||
|
||||
@router.post("/api/v1/git/tags")
|
||||
def client_git_tags(request: Request, body: GitTagsRequest = Body(...)):
|
||||
identity = request.state.device_identity
|
||||
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
|
||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||
|
||||
policy = policy_row_to_dict(policy_repository.get_policy(body.app_id, body.channel))
|
||||
if not policy["git_tags_enabled"]:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": "git_tags_disabled", "msg": "当前策略未允许生成 Git 标签清单"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = load_git_tags(force_refresh=False)
|
||||
return {
|
||||
"generated_at": payload["generated_at"],
|
||||
"repo_count": payload["repo_count"],
|
||||
"tag_count": payload["tag_count"],
|
||||
"tags_text": payload["tags_text"],
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"获取 Git 标签失败:{exc}") from exc
|
||||
@@ -17,6 +17,7 @@ def save_policy(
|
||||
valid_until: str,
|
||||
min_supported_version: str,
|
||||
disabled_versions_json: str,
|
||||
git_tags_enabled: bool,
|
||||
message: str,
|
||||
) -> tuple[str, int | None]:
|
||||
conn = db.get_conn()
|
||||
@@ -36,6 +37,7 @@ def save_policy(
|
||||
valid_until,
|
||||
min_supported_version,
|
||||
disabled_versions_json,
|
||||
int(bool(git_tags_enabled)),
|
||||
message,
|
||||
app_id,
|
||||
channel,
|
||||
@@ -43,13 +45,13 @@ def save_policy(
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
|
||||
min_supported_version,disabled_versions,message,app_id,channel)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
min_supported_version,disabled_versions,git_tags_enabled,message,app_id,channel)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
|
||||
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
|
||||
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
|
||||
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
|
||||
message=excluded.message,updated_at=datetime('now')
|
||||
git_tags_enabled=excluded.git_tags_enabled,message=excluded.message,updated_at=datetime('now')
|
||||
""",
|
||||
values,
|
||||
)
|
||||
|
||||
@@ -39,6 +39,11 @@ class DownloadReportRequest(BaseModel):
|
||||
files: list[dict]
|
||||
|
||||
|
||||
class GitTagsRequest(BaseModel):
|
||||
app_id: str
|
||||
channel: str
|
||||
|
||||
|
||||
class DeviceIssueRequest(BaseModel):
|
||||
app_id: str
|
||||
channel: str
|
||||
|
||||
@@ -10,4 +10,5 @@ class PolicySaveRequest(BaseModel):
|
||||
valid_until: str = ""
|
||||
min_supported_version: str = ""
|
||||
disabled_versions: list[str] = Field(default_factory=list)
|
||||
git_tags_enabled: bool = False
|
||||
message: str = ""
|
||||
|
||||
@@ -13,6 +13,7 @@ def policy_row_to_dict(row) -> dict:
|
||||
"valid_until": "2099-12-31T23:59:59Z",
|
||||
"min_supported_version": "",
|
||||
"disabled_versions": [],
|
||||
"git_tags_enabled": False,
|
||||
"message": "",
|
||||
}
|
||||
try:
|
||||
@@ -27,6 +28,7 @@ def policy_row_to_dict(row) -> dict:
|
||||
"valid_until": row["valid_until"],
|
||||
"min_supported_version": row["min_supported_version"] or "",
|
||||
"disabled_versions": disabled if isinstance(disabled, list) else [],
|
||||
"git_tags_enabled": bool(row["git_tags_enabled"]) if "git_tags_enabled" in row.keys() else False,
|
||||
"message": row["message"] or "",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
_CACHE: dict[str, Any] = {
|
||||
"expires_at": 0.0,
|
||||
"payload": None,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.getenv("GITEA_BASE_URL", "https://git.alimzs.com:6443").rstrip("/")
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
return os.getenv("GITEA_TOKEN", "").strip()
|
||||
|
||||
|
||||
def _request_json(path: str) -> Any:
|
||||
headers = {"Accept": "application/json"}
|
||||
token = _token()
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
context = None
|
||||
if not _env_bool("GITEA_VERIFY_SSL", False):
|
||||
context = ssl._create_unverified_context()
|
||||
req = urllib.request.Request(_base_url() + path, headers=headers)
|
||||
timeout = float(os.getenv("GITEA_TIMEOUT_SEC", "12"))
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=context) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _paged(path: str) -> list[Any]:
|
||||
result: list[Any] = []
|
||||
page = 1
|
||||
while True:
|
||||
sep = "&" if "?" in path else "?"
|
||||
data = _request_json(f"{path}{sep}limit=100&page={page}")
|
||||
if isinstance(data, dict) and "data" in data:
|
||||
data = data.get("data") or []
|
||||
if not data:
|
||||
break
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("Gitea API returned an unexpected response.")
|
||||
result.extend(data)
|
||||
page += 1
|
||||
return result
|
||||
|
||||
|
||||
def _repo_list() -> list[dict[str, Any]]:
|
||||
if _token():
|
||||
repos = _paged("/api/v1/user/repos")
|
||||
else:
|
||||
repos = _paged("/api/v1/repos/search")
|
||||
return [repo for repo in repos if isinstance(repo, dict) and repo.get("full_name")]
|
||||
|
||||
|
||||
def _repo_tags(full_name: str) -> list[str]:
|
||||
owner, name = full_name.split("/", 1)
|
||||
owner_q = urllib.parse.quote(owner, safe="")
|
||||
name_q = urllib.parse.quote(name, safe="")
|
||||
tags = _paged(f"/api/v1/repos/{owner_q}/{name_q}/tags")
|
||||
return [str(item.get("name")) for item in tags if isinstance(item, dict) and item.get("name")]
|
||||
|
||||
|
||||
def format_tags_text(repositories: list[dict[str, Any]], generated_at: str) -> str:
|
||||
lines = [
|
||||
"# SimCAE Git Tag List",
|
||||
f"# Generated at: {generated_at}",
|
||||
f"# Source: {_base_url()}",
|
||||
"",
|
||||
]
|
||||
for repo in repositories:
|
||||
visibility = "private" if repo.get("private") else "public"
|
||||
lines.append(f"[{repo['full_name']}]")
|
||||
lines.append(f"default_branch={repo.get('default_branch') or ''}")
|
||||
lines.append(f"visibility={visibility}")
|
||||
tags = repo.get("tags") or []
|
||||
if tags:
|
||||
lines.extend(str(tag) for tag in tags)
|
||||
else:
|
||||
lines.append("(no tags)")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def load_git_tags(force_refresh: bool = False) -> dict[str, Any]:
|
||||
ttl = max(0, int(os.getenv("GITEA_TAG_CACHE_TTL_SEC", "300") or "300"))
|
||||
now = time.time()
|
||||
if not force_refresh and _CACHE["payload"] and now < float(_CACHE["expires_at"]):
|
||||
return _CACHE["payload"]
|
||||
|
||||
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
repositories: list[dict[str, Any]] = []
|
||||
for repo in _repo_list():
|
||||
full_name = str(repo["full_name"])
|
||||
tags = _repo_tags(full_name)
|
||||
repositories.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"private": bool(repo.get("private")),
|
||||
"default_branch": repo.get("default_branch") or "",
|
||||
"html_url": repo.get("html_url") or "",
|
||||
"tags": tags,
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"generated_at": generated_at,
|
||||
"repo_count": len(repositories),
|
||||
"tag_count": sum(len(repo["tags"]) for repo in repositories),
|
||||
"repositories": repositories,
|
||||
"tags_text": format_tags_text(repositories, generated_at),
|
||||
}
|
||||
_CACHE["payload"] = payload
|
||||
_CACHE["expires_at"] = now + ttl
|
||||
return payload
|
||||
Reference in New Issue
Block a user