diff --git a/.env.docker.example b/.env.docker.example index b1e6c90..4dddfcf 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -64,6 +64,14 @@ ADMIN_REFRESH_TOKEN_EXPIRE_DAYS=7 # 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe +# Gitea 标签清单。GITEA_TOKEN 只放服务端,绝不返回给 Launcher。 +# 策略页勾选“允许 Launcher 生成 Git 标签清单”后,服务端会用这里的配置拉取仓库 tags。 +GITEA_BASE_URL=https://git.alimzs.com:6443 +GITEA_TOKEN=7fb1c733e49c6feb50c8fff900d71f7c97ebfa3d +GITEA_VERIFY_SSL=false +GITEA_TAG_CACHE_TTL_SEC=300 +GITEA_TIMEOUT_SEC=12 + # 管理页生成 app_config.json 时使用的客户端默认值。普通 Windows SimCAE 部署通常不用改。 # Linux 部署可以改成: # CLIENT_MAIN_EXECUTABLE=SimCAE diff --git a/Docs/01-服务端Docker打包部署指南.md b/Docs/01-服务端Docker打包部署指南.md index ffbed6c..9dc98eb 100644 --- a/Docs/01-服务端Docker打包部署指南.md +++ b/Docs/01-服务端Docker打包部署指南.md @@ -226,6 +226,18 @@ MinIO 控制台: http://你的服务器IP:9001/ | `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 | | `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 | +### Git 标签清单参数 + +这些字段用于管理后台策略里的“允许 Launcher 生成 Git 标签清单”。开启后,Launcher 会请求服务端生成 `tags.txt`;服务端用这里的 Gitea 配置访问仓库 tags,然后只把整理后的文本返回给 Launcher。 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `GITEA_BASE_URL` | 可不填 | 可以 | Gitea 服务地址,默认 `https://git.alimzs.com:6443`。如果公司 Git 地址变化,就改这里。 | +| `GITEA_TOKEN` | 查私有仓库时必填 | 不填只能查公开仓库 | Gitea 访问令牌。只保存在服务端 `.env`,不要写进客户端配置,不要提交 Git,不会返回给 Launcher。 | +| `GITEA_VERIFY_SSL` | 可不填 | 可以 | 是否校验 Gitea HTTPS 证书。内网自签证书可保持 `false`;正式可信证书建议改成 `true`。 | +| `GITEA_TAG_CACHE_TTL_SEC` | 可不填 | 可以 | tags 缓存时间,默认 `300` 秒,避免每次打开页面或启动 Launcher 都打满 Git 服务。 | +| `GITEA_TIMEOUT_SEC` | 可不填 | 可以 | 单次请求 Gitea 的超时时间,默认 `12` 秒。 | + ### 发布保护参数 这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。 diff --git a/admin-ui/src/views/simcae/index.vue b/admin-ui/src/views/simcae/index.vue index eec7a14..124fdd0 100644 --- a/admin-ui/src/views/simcae/index.vue +++ b/admin-ui/src/views/simcae/index.vue @@ -84,6 +84,7 @@ const policyForm = reactive({ min_supported_version: "", valid_until: "", message: "", + git_tags_enabled: false, policy_seq: null }); const currentPolicy = reactive({ @@ -95,6 +96,7 @@ const currentPolicy = reactive({ min_supported_version: "", valid_until: "2099-12-31T23:59:59Z", disabled_versions: [], + git_tags_enabled: false, message: "", policy_seq: null, saved: false @@ -139,6 +141,10 @@ const releaseManifestError = ref(""); const installRootDetectMessage = ref(""); const installRootDialogVisible = ref(false); const installRootDialogReason = ref(""); +const gitTagsPreview = ref(""); +const gitTagsSummary = ref(""); +const gitTagsLoading = ref(false); +const gitTagsError = ref(""); let dashboardResizeObserver: ResizeObserver | null = null; const enabledChannels = computed(() => @@ -693,6 +699,42 @@ async function loadPolicy() { disabled_versions: data?.disabled_versions || [] }); policyDisabledText.value = (data?.disabled_versions || []).join(", "); + if (policyForm.git_tags_enabled) { + await loadGitTagsPreview(false); + } else { + gitTagsPreview.value = ""; + gitTagsSummary.value = ""; + gitTagsError.value = ""; + } +} + +async function loadGitTagsPreview(forceRefresh = true) { + gitTagsLoading.value = true; + gitTagsError.value = ""; + try { + const data = await adminRequest( + `/admin/git/tags?force_refresh=${forceRefresh ? "true" : "false"}` + ); + gitTagsPreview.value = data?.tags_text || ""; + gitTagsSummary.value = `仓库 ${data?.repo_count || 0} 个,标签 ${data?.tag_count || 0} 个,生成时间 ${data?.generated_at || "未知"}`; + } catch (error) { + gitTagsPreview.value = ""; + gitTagsSummary.value = ""; + gitTagsError.value = friendlyError(error); + ElMessage.error(`获取 Git 标签失败:${gitTagsError.value}`); + } finally { + gitTagsLoading.value = false; + } +} + +async function handlePolicyGitTagsChange(value: boolean) { + if (value) { + await loadGitTagsPreview(true); + } else { + gitTagsPreview.value = ""; + gitTagsSummary.value = ""; + gitTagsError.value = ""; + } } async function savePolicy() { @@ -1407,6 +1449,9 @@ test_file/* {{ currentPolicy.offline_allowed ? "允许" : "禁止" }} + + {{ currentPolicy.git_tags_enabled ? "生成" : "不生成" }} + {{ currentPolicy.min_supported_version || "无" }} {{ currentPolicy.valid_until || "未设置" }} {{ currentPolicyDisabledText }} @@ -1423,6 +1468,26 @@ test_file/* 强制更新 允许回滚 允许离线启动 + 允许 Launcher 生成 Git 标签清单 + +
+
+
+ Git 标签清单预览 +
服务端使用 Gitea Token 拉取仓库和标签,页面只展示整理后的结果,不会暴露 Token。
+
+ 刷新标签 +
+ +
{{ gitTagsSummary }}
+
{{ gitTagsPreview || "尚未获取 Git 标签清单" }}
保存策略 @@ -2083,6 +2148,15 @@ test_file/* font-size: 12px; } +.git-tags-preview { + padding: 14px; + margin: 16px 0; + background: linear-gradient(135deg, #f8fbff, #fff); + border: 1px solid rgb(64 158 255 / 18%); + border-left: 4px solid #2563eb; + border-radius: 8px; +} + .card-header-row { display: flex; align-items: center; @@ -2313,6 +2387,12 @@ test_file/* min-height: 96px; } +.git-tags-code { + min-height: 160px; + max-height: 320px; + margin-top: 12px; +} + @media (max-width: 1200px) { .metric-grid, .form-grid, diff --git a/app/api/router.py b/app/api/router.py index c8fbb67..f79b53a 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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) diff --git a/app/api/routes/admin_policy.py b/app/api/routes/admin_policy.py index c0e1dc1..69baf68 100644 --- a/app/api/routes/admin_policy.py +++ b/app/api/routes/admin_policy.py @@ -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": diff --git a/app/api/routes/client_update.py b/app/api/routes/client_update.py index 0213487..a5a0b6d 100644 --- a/app/api/routes/client_update.py +++ b/app/api/routes/client_update.py @@ -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, diff --git a/app/api/routes/git_tags.py b/app/api/routes/git_tags.py new file mode 100644 index 0000000..1f5ee98 --- /dev/null +++ b/app/api/routes/git_tags.py @@ -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 diff --git a/app/repositories/policy_repository.py b/app/repositories/policy_repository.py index 7a17ebc..94f39d0 100644 --- a/app/repositories/policy_repository.py +++ b/app/repositories/policy_repository.py @@ -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, ) diff --git a/app/schemas/client_update.py b/app/schemas/client_update.py index 822ae0d..79b7792 100644 --- a/app/schemas/client_update.py +++ b/app/schemas/client_update.py @@ -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 diff --git a/app/schemas/policy.py b/app/schemas/policy.py index 9b89b82..cd1e447 100644 --- a/app/schemas/policy.py +++ b/app/schemas/policy.py @@ -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 = "" diff --git a/app/services/common_service.py b/app/services/common_service.py index f293c7b..bc4ff91 100644 --- a/app/services/common_service.py +++ b/app/services/common_service.py @@ -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 "", } diff --git a/app/services/git_tags_service.py b/app/services/git_tags_service.py new file mode 100644 index 0000000..69c411b --- /dev/null +++ b/app/services/git_tags_service.py @@ -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 diff --git a/main.py b/main.py index e91c902..db64313 100644 --- a/main.py +++ b/main.py @@ -55,6 +55,9 @@ async def lifespan(app: FastAPI): license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()} if "license_key_cipher" not in license_columns: cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''") + policy_columns = {row[1] for row in cur.execute("PRAGMA table_info(version_policies)").fetchall()} + if "git_tags_enabled" not in policy_columns: + cur.execute("ALTER TABLE version_policies ADD COLUMN git_tags_enabled INTEGER NOT NULL DEFAULT 0") audit_columns = {row[1] for row in cur.execute("PRAGMA table_info(admin_audit_logs)").fetchall()} if "actor_username" not in audit_columns: cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_username TEXT NOT NULL DEFAULT ''") diff --git a/scripts/package-offline-server.sh b/scripts/package-offline-server.sh index 12942e1..1ee439f 100755 --- a/scripts/package-offline-server.sh +++ b/scripts/package-offline-server.sh @@ -319,6 +319,7 @@ MinIO 控制台: http://你的服务器IP:9001/ | `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 | | `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 | | `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | +| `GITEA_BASE_URL` / `GITEA_TOKEN` | 可不填 | 可以 | 策略页勾选“允许 Launcher 生成 Git 标签清单”时使用。`GITEA_TOKEN` 只保存在服务端,用来访问 Gitea 仓库和 tags,不会返回给客户端。只查公开仓库时可不填 Token;要查私有仓库必须填写。 | | `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | | `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | diff --git a/tables.sql b/tables.sql index aa055e3..3283498 100644 --- a/tables.sql +++ b/tables.sql @@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS version_policies ( valid_until TEXT NOT NULL, min_supported_version TEXT NOT NULL DEFAULT '', disabled_versions TEXT NOT NULL DEFAULT '[]', + git_tags_enabled INTEGER NOT NULL DEFAULT 0, message TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), diff --git a/tests/test_admin_update_flow.py b/tests/test_admin_update_flow.py index 03574ef..1301081 100644 --- a/tests/test_admin_update_flow.py +++ b/tests/test_admin_update_flow.py @@ -3,6 +3,8 @@ import asyncio import json from types import SimpleNamespace +from fastapi import HTTPException + def fake_request(**kwargs): return SimpleNamespace( @@ -95,6 +97,63 @@ def test_admin_login_license_and_device_issue(server_module): asyncio.run(run_admin_login_license_and_device_issue(server_module)) +async def run_git_tags_policy_gate(server_module, monkeypatch): + async with server_module.lifespan(server_module.app): + from app.api.routes import admin_policy, client_update, git_tags + from app.schemas.client_update import DeviceIssueRequest, GitTagsRequest + from app.schemas.policy import PolicySaveRequest + + add_app("gitapp", "Git App") + license_key = create_license("gitapp", "stable") + issue_data = await client_update.issue_device( + fake_request(), + DeviceIssueRequest( + app_id="gitapp", + channel="stable", + license_key=license_key, + installation_id="install-git-tags-000001", + machine_hash="cd" * 32, + ), + ) + request = fake_request(state={"device_identity": issue_data["identity"]}) + body = GitTagsRequest(app_id="gitapp", channel="stable") + + try: + git_tags.client_git_tags(request, body) + assert False, "Git tags should be blocked before the policy is enabled" + except HTTPException as exc: + assert exc.status_code == 403 + assert exc.detail["error"] == "git_tags_disabled" + + admin_policy.admin_save_policy( + PolicySaveRequest( + app_id="gitapp", + channel="stable", + valid_until="2099-12-31T23:59:59Z", + git_tags_enabled=True, + ) + ) + monkeypatch.setattr( + git_tags, + "load_git_tags", + lambda force_refresh=False: { + "generated_at": "2026-07-20T00:00:00Z", + "repo_count": 1, + "tag_count": 2, + "tags_text": "[owner/repo]\nv1.0.0\nv1.0.1\n", + }, + ) + + result = git_tags.client_git_tags(request, body) + assert result["repo_count"] == 1 + assert result["tag_count"] == 2 + assert "v1.0.1" in result["tags_text"] + + +def test_git_tags_policy_gate(server_module, monkeypatch): + asyncio.run(run_git_tags_policy_gate(server_module, monkeypatch)) + + async def run_publish_payload_creates_version(server_module, tmp_path): async with server_module.lifespan(server_module.app): from app.api.routes import admin_version