feat: 支持 Linux 客户端配置与发布校验

This commit is contained in:
2026-07-14 08:43:58 +00:00
parent 6df8e3ad31
commit 1b4293e57c
10 changed files with 173 additions and 36 deletions
+39 -4
View File
@@ -1,11 +1,12 @@
import json
import os
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.config import ENV_FILE_PATH
from app.core.security import admin_auth, persist_env_value, set_admin_token
from app.repositories import app_channel_repository
from app.core.security import admin_auth, persist_env_value, set_admin_token, token_digest
from app.repositories import app_channel_repository, license_repository
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
from app.services.admin_config_service import (
PUBLISH_MAX_FIELDS,
@@ -18,6 +19,7 @@ from app.services.admin_config_service import (
crash_report_test_values,
crash_report_test_text,
normalize_executable_name,
platform_executable_path,
public_api_base_url,
)
from app.services.common_service import validate_channel_code
@@ -26,6 +28,31 @@ from app.services.common_service import validate_channel_code
router = APIRouter(tags=["admin-config"])
def client_config_license_warning(license_key: str, app_id: str, channel: str) -> str:
if not license_key:
return ""
license_row = license_repository.get_license_by_hash_with_usage(token_digest(license_key))
if not license_row:
return "当前 License 不存在或已删除,生成的客户端配置未写入 License,请创建或申请新的 License。"
if license_row["app_id"] != app_id or license_row["channel_code"] != channel:
return "当前 License 不属于所选应用或渠道,生成的客户端配置未写入 License,请选择匹配的 License。"
if license_row["status"] != "active":
return "当前 License 已禁用,生成的客户端配置未写入 License,请启用该 License 或创建新的 License。"
try:
expiry = datetime.fromisoformat(str(license_row["valid_until"]).replace("Z", "+00:00"))
except ValueError:
expiry = datetime.now(timezone.utc)
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
if expiry <= datetime.now(timezone.utc):
return "当前 License 已过期,生成的客户端配置未写入 License,请创建或申请新的 License。"
used_devices = int(license_row["used_devices"] or 0)
max_devices = int(license_row["max_devices"] or 0)
if used_devices >= max_devices:
return f"当前 License 已绑定 {used_devices}/{max_devices} 台设备,已达到上限。生成的客户端配置未写入 License,请创建或申请新的 License。"
return ""
@router.get("/admin/auth/check")
def admin_auth_check(auth=Depends(admin_auth)):
return {"code": 0, "msg": "管理员令牌有效"}
@@ -83,7 +110,14 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
defaults = default_client_config_values(request)
main_executable = normalize_executable_name(body.main_executable) or defaults["main_executable"]
main_executable = platform_executable_path(
normalize_executable_name(body.main_executable),
defaults["main_executable"],
)
license_key = body.license_key.strip()
license_warning = client_config_license_warning(license_key, app_id, channel)
if license_warning:
license_key = ""
config = {
"app_id": app_id,
"app_name": app_row["app_name"],
@@ -91,7 +125,7 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
"current_version": current_version,
"client_protocol": str(client_protocol),
"launch_token": defaults["launch_token"],
"license_key": body.license_key.strip(),
"license_key": license_key,
"api_base_url": api_base_url,
"client_token": defaults["client_token"],
"request_timeout_ms": defaults["request_timeout_ms"],
@@ -110,6 +144,7 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
return {
"config": config,
"json_text": json.dumps(config, ensure_ascii=False, indent=2),
"license_warning": license_warning,
"crash_test_config": crash_test_config,
"crash_test_text": crash_report_test_text(api_base_url),
}
+2 -2
View File
@@ -14,8 +14,8 @@ from app.services.common_service import is_executable_path
from app.services.signing_service import canonical_manifest_bytes, sign_manifest, sign_policy
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64").strip() or "x64"
SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["admin-version"])
+2 -2
View File
@@ -17,8 +17,8 @@ from app.services.common_service import is_executable_path, policy_row_to_dict,
from app.services.signing_service import canonical_manifest_bytes, sign_device_identity, sign_manifest, sign_policy
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64")
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64").strip() or "x64"
SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["client-update"])
+11
View File
@@ -51,6 +51,17 @@ def list_licenses(app_id: str = "", include_deleted: bool = False) -> list:
return rows
def get_license_by_hash_with_usage(license_key_hash: str):
conn = db.get_conn()
row = conn.execute(
"""SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices
FROM licenses l WHERE l.license_key_hash=? AND l.status<>'deleted'""",
(license_key_hash,),
).fetchone()
conn.close()
return row
def set_license_status(license_id: str, status: str) -> int:
conn = db.get_conn()
cur = conn.execute(
+32 -8
View File
@@ -7,9 +7,30 @@ from fastapi import HTTPException
PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64").strip() or "x64"
def platform_executable_path(value: str, fallback_base_name: str) -> str:
path = str(value or "").strip().replace(chr(92), "/").strip("/")
if not path:
path = fallback_base_name
parts = path.split("/")
leaf = parts[-1]
if TARGET_PLATFORM == "windows":
if "." not in leaf:
leaf += ".exe"
elif TARGET_PLATFORM == "linux":
if leaf.casefold().endswith(".exe"):
leaf = leaf[:-4]
parts[-1] = leaf
return "/".join(parts)
RELEASE_MAIN_EXECUTABLE = platform_executable_path(
os.getenv("RELEASE_MAIN_EXECUTABLE", ""),
"bin/SimCAE",
)
PUBLISH_MAX_REQUEST_BYTES = int(os.getenv("PUBLISH_MAX_REQUEST_MB", "4096")) * 1024 * 1024
PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
@@ -31,7 +52,7 @@ def default_client_main_executable() -> str:
path = RELEASE_MAIN_EXECUTABLE.strip().replace(chr(92), "/").strip("/")
if path.casefold().startswith("bin/"):
path = path[4:]
return path or "SimCAE.exe"
return platform_executable_path(path, "SimCAE")
def default_client_config_values(request: Request) -> dict:
@@ -42,10 +63,13 @@ def default_client_config_values(request: Request) -> dict:
"request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
"temp_folder": os.getenv("CLIENT_TEMP_FOLDER", "update_temp"),
"install_root": os.getenv("CLIENT_INSTALL_ROOT", ".."),
"main_executable": os.getenv("CLIENT_MAIN_EXECUTABLE", default_client_main_executable()),
"launcher_executable": os.getenv("CLIENT_LAUNCHER_EXECUTABLE", "Launcher.exe"),
"updater_executable": os.getenv("CLIENT_UPDATER_EXECUTABLE", "Updater.exe"),
"bootstrap_executable": os.getenv("CLIENT_BOOTSTRAP_EXECUTABLE", "Bootstrap.exe"),
"main_executable": platform_executable_path(
os.getenv("CLIENT_MAIN_EXECUTABLE", ""),
default_client_main_executable(),
),
"launcher_executable": platform_executable_path(os.getenv("CLIENT_LAUNCHER_EXECUTABLE", ""), "Launcher"),
"updater_executable": platform_executable_path(os.getenv("CLIENT_UPDATER_EXECUTABLE", ""), "Updater"),
"bootstrap_executable": platform_executable_path(os.getenv("CLIENT_BOOTSTRAP_EXECUTABLE", ""), "Bootstrap"),
"health_check_timeout_ms": os.getenv("CLIENT_HEALTH_CHECK_TIMEOUT_MS", "15000"),
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
+9 -1
View File
@@ -18,7 +18,15 @@ from app.repositories import publish_repository
from app.services.common_service import require_channel
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
DEFAULT_RELEASE_MAIN_EXECUTABLE = "bin/SimCAE" if TARGET_PLATFORM == "linux" else "bin/SimCAE.exe"
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", DEFAULT_RELEASE_MAIN_EXECUTABLE).strip().replace(chr(92), "/").strip("/")
if TARGET_PLATFORM == "linux" and RELEASE_MAIN_EXECUTABLE.casefold().endswith(".exe"):
RELEASE_MAIN_EXECUTABLE = RELEASE_MAIN_EXECUTABLE[:-4]
elif TARGET_PLATFORM == "windows":
leaf = RELEASE_MAIN_EXECUTABLE.rsplit("/", 1)[-1]
if "." not in leaf:
RELEASE_MAIN_EXECUTABLE += ".exe"
UPLOAD_SPOOL_DIR = configured_path("UPLOAD_SPOOL_DIR", "upload_spool")
UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True)
tempfile.tempdir = str(UPLOAD_SPOOL_DIR)