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
+12 -3
View File
@@ -18,11 +18,14 @@ SERVICE_TITLE=SimCAE Update Service
# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。
CORS_ALLOW_ORIGINS=*
# 发布包目标平台。当前客户端是 Windows x64,通常不用改
# 发布包目标平台。Windows 客户端填 windowsLinux 客户端填 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
+16 -5
View File
@@ -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.exeLinux 目录内应包含 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`
## 七、常用命令
+34 -6
View File
@@ -28,6 +28,7 @@ const currentAppId = ref("");
const createdLicenseKey = ref("");
const generatedClientConfig = ref("");
const generatedCrashTestInfo = ref("");
const licenseConfigWarning = ref("");
const runtime = reactive<any>({});
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<any>("/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);
<el-table-column prop="license_id" label="License ID" min-width="190" />
<el-table-column prop="customer_name" label="客户" min-width="140" />
<el-table-column prop="channel_code" label="渠道" width="100" />
<el-table-column label="设备" width="110"><template #default="{ row }">{{ row.used_devices }} / {{ row.max_devices }}</template></el-table-column>
<el-table-column label="设备" width="130"><template #default="{ row }"><span>{{ row.used_devices }} / {{ row.max_devices }}</span><el-tag v-if="isLicenseFull(row)" class="ml-xs" type="warning" size="small">已满</el-tag></template></el-table-column>
<el-table-column prop="valid_until" label="有效期" min-width="170" />
<el-table-column label="License" min-width="260"><template #default="{ row }"><span v-if="row.license_key" class="mono">{{ row.license_key }}</span><el-tag v-else type="info">旧授权不可查看</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "有效" : "禁用" }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="300"><template #default="{ row }"><el-button v-if="row.license_key" link type="primary" @click="fillLicense(row)">填入配置</el-button><el-button v-if="row.license_key" link type="primary" @click="copyLicense(row)">复制</el-button><el-button link :type="row.status === 'active' ? 'danger' : 'success'" @click="toggleLicense(row)">{{ row.status === "active" ? "禁用" : "启用" }}</el-button><el-button link type="danger" @click="deleteLicense(row)">删除</el-button></template></el-table-column>
<el-table-column label="操作" width="320"><template #default="{ row }"><el-button v-if="row.license_key" link type="primary" :disabled="isLicenseFull(row) || row.status !== 'active'" @click="fillLicense(row)">填入配置</el-button><el-button v-if="row.license_key" link type="primary" @click="copyLicense(row)">复制</el-button><el-button link :type="row.status === 'active' ? 'danger' : 'success'" @click="toggleLicense(row)">{{ row.status === "active" ? "禁用" : "启用" }}</el-button><el-button link type="danger" @click="deleteLicense(row)">删除</el-button></template></el-table-column>
</el-table>
</el-card>
@@ -925,6 +948,7 @@ onMounted(loadAll);
<el-form-item label="主程序文件名"><el-input v-model="configForm.main_executable" placeholder="例如 SimCAE.exe" /></el-form-item>
<el-form-item label="License"><el-input v-model="configForm.license_key" placeholder="可从上方 License 列表填入;也可留空让客户端首次启动时输入" /></el-form-item>
</el-form>
<el-alert v-if="licenseConfigWarning" class="mb" type="warning" :closable="false" show-icon :title="licenseConfigWarning" />
<div class="action-row"><el-button type="primary" @click="generateClientConfig">生成配套配置</el-button><el-button :disabled="!generatedClientConfig" @click="copyGeneratedConfig">复制配置</el-button></div>
<div class="config-section-title">客户端 app_config.json</div>
<pre class="code-block">{{ generatedClientConfig || "尚未生成客户端配置" }}</pre>
@@ -1149,6 +1173,10 @@ onMounted(loadAll);
margin-top: 12px;
}
.ml-xs {
margin-left: 6px;
}
.mono {
display: inline-block;
max-width: 360px;
+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)
+16 -5
View File
@@ -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.exeLinux 目录内应包含 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`。
## 七、常用命令