diff --git a/.env.docker.example b/.env.docker.example index 5accb67..7465c0a 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -18,11 +18,14 @@ SERVICE_TITLE=SimCAE Update Service # 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。 CORS_ALLOW_ORIGINS=* -# 发布包目标平台。当前客户端是 Windows x64,通常不用改。 +# 发布包目标平台。Windows 客户端填 windows;Linux 客户端填 linux。 +# 改成 linux 后,管理页生成 app_config.json 时会默认使用 Launcher/Updater/Bootstrap 这类无 .exe 文件名。 TARGET_PLATFORM=windows TARGET_ARCH=x64 -# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。 +# 业务主程序相对发布根目录的路径。 +# Windows 示例:bin/SimCAE.exe +# Linux 示例:bin/SimCAE RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe # Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。 @@ -49,7 +52,13 @@ ADMIN_TOKEN=SimCAEAdminToken2026 # 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe -# 管理页生成 app_config.json 时使用的客户端默认值。普通 SimCAE 部署通常不用改。 +# 管理页生成 app_config.json 时使用的客户端默认值。普通 Windows SimCAE 部署通常不用改。 +# Linux 部署可以改成: +# CLIENT_MAIN_EXECUTABLE=SimCAE +# CLIENT_LAUNCHER_EXECUTABLE=Launcher +# CLIENT_UPDATER_EXECUTABLE=Updater +# CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap +# 如果 TARGET_PLATFORM=linux 但这里仍写了 .exe,后端生成配置时会自动去掉 .exe。 CLIENT_LAUNCH_TOKEN=SimCAE_Launch_Token_2026_ChangeMe_32Bytes CLIENT_REQUEST_TIMEOUT_MS=5000 CLIENT_TEMP_FOLDER=update_temp diff --git a/Docs/服务端部署说明.md b/Docs/服务端部署说明.md index fde728a..b182ea1 100644 --- a/Docs/服务端部署说明.md +++ b/Docs/服务端部署说明.md @@ -187,10 +187,10 @@ MinIO 控制台: http://你的服务器IP:9001/ | 字段 | 是否必填 | 默认能否直接用 | 怎么填 | | --- | --- | --- | --- | -| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | +| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | | `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | | `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 | -| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 | +| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`;Linux 示例:`bin/SimCAE`。 | | `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | | `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | | `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | @@ -205,7 +205,7 @@ MinIO 控制台: http://你的服务器IP:9001/ | `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | | `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | | `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | -| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 | +| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | Windows x64 填 `windows` / `x64`;Linux x64 填 `linux` / `x64`。 | | `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 | | `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | | `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | @@ -284,16 +284,27 @@ CLIENT_API_TOKEN=SimCAEClientToken2026 进入后台后,“发布新版本”支持两种方式: ```text -1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe +1. 选择软件发布根目录,例如 SimCAE 目录。Windows 目录内应包含 bin/SimCAE.exe;Linux 目录内应包含 bin/SimCAE 2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar ``` -压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: +压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是: ```text RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe ``` +Linux 客户端部署时建议改成: + +```text +TARGET_PLATFORM=linux +RELEASE_MAIN_EXECUTABLE=bin/SimCAE +CLIENT_MAIN_EXECUTABLE=SimCAE +CLIENT_LAUNCHER_EXECUTABLE=Launcher +CLIENT_UPDATER_EXECUTABLE=Updater +CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap +``` + `.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。 ## 七、常用命令 diff --git a/admin-ui/src/views/simcae/index.vue b/admin-ui/src/views/simcae/index.vue index a43cbc7..68ce254 100644 --- a/admin-ui/src/views/simcae/index.vue +++ b/admin-ui/src/views/simcae/index.vue @@ -28,6 +28,7 @@ const currentAppId = ref(""); const createdLicenseKey = ref(""); const generatedClientConfig = ref(""); const generatedCrashTestInfo = ref(""); +const licenseConfigWarning = ref(""); const runtime = reactive({}); const appForm = reactive({ app_id: "", app_name: "" }); @@ -193,6 +194,14 @@ function handleError(prefix: string, error: unknown) { ElMessage.error(`${prefix}:${friendlyError(error)}`); } +function isLicenseFull(row: any) { + return Number(row?.used_devices || 0) >= Number(row?.max_devices || 0); +} + +function isLicenseUsable(row: any) { + return row?.status === "active" && row?.license_key && !isLicenseFull(row); +} + async function loadRuntime() { const data = await adminRequest("/admin/runtime-config"); Object.assign(runtime, data || {}); @@ -434,10 +443,9 @@ async function loadLicenses() { const usable = licenses.value.find( item => - item.status === "active" && - item.license_key && + isLicenseUsable(item) && item.channel_code === publishForm.channel - ) || licenses.value.find(item => item.status === "active" && item.license_key); + ) || licenses.value.find(item => isLicenseUsable(item)); if (usable && !configForm.license_key) configForm.license_key = usable.license_key; } @@ -457,8 +465,17 @@ async function createLicense() { } function fillLicense(row: any) { + if (isLicenseFull(row)) { + ElMessage.warning("这张 License 的设备数已达到上限,请创建或申请新的 License。"); + return; + } + if (row.status !== "active") { + ElMessage.warning("这张 License 当前不可用,请启用后再填入配置。"); + return; + } configForm.license_key = row.license_key; createdLicenseKey.value = row.license_key; + licenseConfigWarning.value = ""; ElMessage.success("License 已填入客户端配置"); } @@ -530,7 +547,13 @@ async function generateClientConfig() { generatedCrashTestInfo.value = result.crash_test_text || JSON.stringify(result.crash_test_config || {}, null, 2); - ElMessage.success("客户端配置已生成"); + licenseConfigWarning.value = result.license_warning || ""; + if (licenseConfigWarning.value) { + configForm.license_key = result.config?.license_key || ""; + ElMessage.warning(licenseConfigWarning.value); + } else { + ElMessage.success("客户端配置已生成"); + } } catch (error) { handleError("生成客户端配置失败", error); } @@ -910,11 +933,11 @@ onMounted(loadAll); - + - + @@ -925,6 +948,7 @@ onMounted(loadAll); +
生成配套配置复制配置
客户端 app_config.json
{{ generatedClientConfig || "尚未生成客户端配置" }}
@@ -1149,6 +1173,10 @@ onMounted(loadAll); margin-top: 12px; } +.ml-xs { + margin-left: 6px; +} + .mono { display: inline-block; max-width: 360px; diff --git a/app/api/routes/admin_config.py b/app/api/routes/admin_config.py index 44e0fe5..b742162 100644 --- a/app/api/routes/admin_config.py +++ b/app/api/routes/admin_config.py @@ -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), } diff --git a/app/api/routes/admin_version.py b/app/api/routes/admin_version.py index b1de16e..f6ef639 100644 --- a/app/api/routes/admin_version.py +++ b/app/api/routes/admin_version.py @@ -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"]) diff --git a/app/api/routes/client_update.py b/app/api/routes/client_update.py index 5e2d84e..4ad47d9 100644 --- a/app/api/routes/client_update.py +++ b/app/api/routes/client_update.py @@ -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"]) diff --git a/app/repositories/license_repository.py b/app/repositories/license_repository.py index c779ae0..b21768d 100644 --- a/app/repositories/license_repository.py +++ b/app/repositories/license_repository.py @@ -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( diff --git a/app/services/admin_config_service.py b/app/services/admin_config_service.py index ad4841e..1c8e48b 100644 --- a/app/services/admin_config_service.py +++ b/app/services/admin_config_service.py @@ -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, diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 06b8c2f..093a98d 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -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) diff --git a/scripts/package-offline-server.sh b/scripts/package-offline-server.sh index 3dc48ff..296ba91 100755 --- a/scripts/package-offline-server.sh +++ b/scripts/package-offline-server.sh @@ -299,10 +299,10 @@ MinIO 控制台: http://你的服务器IP:9001/ | 字段 | 是否必填 | 默认能否直接用 | 怎么填 | | --- | --- | --- | --- | -| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | +| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | | `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | | `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 | -| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 | +| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`;Linux 示例:`bin/SimCAE`。 | | `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | | `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | | `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | @@ -317,7 +317,7 @@ MinIO 控制台: http://你的服务器IP:9001/ | `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | | `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | | `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | -| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 | +| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | Windows x64 填 `windows` / `x64`;Linux x64 填 `linux` / `x64`。 | | `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 | | `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | | `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | @@ -394,16 +394,27 @@ CLIENT_API_TOKEN=SimCAEClientToken2026 进入后台后,“发布新版本”支持两种方式: ```text -1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe +1. 选择软件发布根目录,例如 SimCAE 目录。Windows 目录内应包含 bin/SimCAE.exe;Linux 目录内应包含 bin/SimCAE 2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar ``` -压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: +压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是: ```text RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe ``` +Linux 客户端部署时建议改成: + +```text +TARGET_PLATFORM=linux +RELEASE_MAIN_EXECUTABLE=bin/SimCAE +CLIENT_MAIN_EXECUTABLE=SimCAE +CLIENT_LAUNCHER_EXECUTABLE=Launcher +CLIENT_UPDATER_EXECUTABLE=Updater +CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap +``` + `.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。 ## 七、常用命令