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=* CORS_ALLOW_ORIGINS=*
# 发布包目标平台。当前客户端是 Windows x64,通常不用改 # 发布包目标平台。Windows 客户端填 windowsLinux 客户端填 linux
# 改成 linux 后,管理页生成 app_config.json 时会默认使用 Launcher/Updater/Bootstrap 这类无 .exe 文件名。
TARGET_PLATFORM=windows TARGET_PLATFORM=windows
TARGET_ARCH=x64 TARGET_ARCH=x64
# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。 # 业务主程序相对发布根目录的路径。
# Windows 示例:bin/SimCAE.exe
# Linux 示例:bin/SimCAE
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。 # Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。
@@ -49,7 +52,13 @@ ADMIN_TOKEN=SimCAEAdminToken2026
# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 # 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。
LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe 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_LAUNCH_TOKEN=SimCAE_Launch_Token_2026_ChangeMe_32Bytes
CLIENT_REQUEST_TIMEOUT_MS=5000 CLIENT_REQUEST_TIMEOUT_MS=5000
CLIENT_TEMP_FOLDER=update_temp 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 被占用,可以改成其他端口。 | | `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP: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` 完全一致。 | | `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | | `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | | `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` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | | `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | | `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | | `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`。只有更换签名体系时才需要改。 | | `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | | `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | | `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
@@ -284,16 +284,27 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
进入后台后,“发布新版本”支持两种方式: 进入后台后,“发布新版本”支持两种方式:
```text ```text
1. 选择软件发布根目录,例如 SimCAE 目录目录内应包含 bin/SimCAE.exe 1. 选择软件发布根目录,例如 SimCAE 目录。Windows 目录内应包含 bin/SimCAE.exeLinux 目录内应包含 bin/SimCAE
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar 2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
``` ```
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env``RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: 压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env``RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
```text ```text
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe 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` `.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`
## 七、常用命令 ## 七、常用命令
+33 -5
View File
@@ -28,6 +28,7 @@ const currentAppId = ref("");
const createdLicenseKey = ref(""); const createdLicenseKey = ref("");
const generatedClientConfig = ref(""); const generatedClientConfig = ref("");
const generatedCrashTestInfo = ref(""); const generatedCrashTestInfo = ref("");
const licenseConfigWarning = ref("");
const runtime = reactive<any>({}); const runtime = reactive<any>({});
const appForm = reactive({ app_id: "", app_name: "" }); const appForm = reactive({ app_id: "", app_name: "" });
@@ -193,6 +194,14 @@ function handleError(prefix: string, error: unknown) {
ElMessage.error(`${prefix}${friendlyError(error)}`); 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() { async function loadRuntime() {
const data = await adminRequest<any>("/admin/runtime-config"); const data = await adminRequest<any>("/admin/runtime-config");
Object.assign(runtime, data || {}); Object.assign(runtime, data || {});
@@ -434,10 +443,9 @@ async function loadLicenses() {
const usable = const usable =
licenses.value.find( licenses.value.find(
item => item =>
item.status === "active" && isLicenseUsable(item) &&
item.license_key &&
item.channel_code === publishForm.channel 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; if (usable && !configForm.license_key) configForm.license_key = usable.license_key;
} }
@@ -457,8 +465,17 @@ async function createLicense() {
} }
function fillLicense(row: any) { 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; configForm.license_key = row.license_key;
createdLicenseKey.value = row.license_key; createdLicenseKey.value = row.license_key;
licenseConfigWarning.value = "";
ElMessage.success("License 已填入客户端配置"); ElMessage.success("License 已填入客户端配置");
} }
@@ -530,7 +547,13 @@ async function generateClientConfig() {
generatedCrashTestInfo.value = generatedCrashTestInfo.value =
result.crash_test_text || result.crash_test_text ||
JSON.stringify(result.crash_test_config || {}, null, 2); JSON.stringify(result.crash_test_config || {}, null, 2);
licenseConfigWarning.value = result.license_warning || "";
if (licenseConfigWarning.value) {
configForm.license_key = result.config?.license_key || "";
ElMessage.warning(licenseConfigWarning.value);
} else {
ElMessage.success("客户端配置已生成"); ElMessage.success("客户端配置已生成");
}
} catch (error) { } catch (error) {
handleError("生成客户端配置失败", 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="license_id" label="License ID" min-width="190" />
<el-table-column prop="customer_name" label="客户" min-width="140" /> <el-table-column prop="customer_name" label="客户" min-width="140" />
<el-table-column prop="channel_code" label="渠道" width="100" /> <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 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="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="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-table>
</el-card> </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="主程序文件名"><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-item label="License"><el-input v-model="configForm.license_key" placeholder="可从上方 License 列表填入;也可留空让客户端首次启动时输入" /></el-form-item>
</el-form> </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="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> <div class="config-section-title">客户端 app_config.json</div>
<pre class="code-block">{{ generatedClientConfig || "尚未生成客户端配置" }}</pre> <pre class="code-block">{{ generatedClientConfig || "尚未生成客户端配置" }}</pre>
@@ -1149,6 +1173,10 @@ onMounted(loadAll);
margin-top: 12px; margin-top: 12px;
} }
.ml-xs {
margin-left: 6px;
}
.mono { .mono {
display: inline-block; display: inline-block;
max-width: 360px; max-width: 360px;
+39 -4
View File
@@ -1,11 +1,12 @@
import json import json
import os import os
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.config import ENV_FILE_PATH from app.core.config import ENV_FILE_PATH
from app.core.security import admin_auth, persist_env_value, set_admin_token from app.core.security import admin_auth, persist_env_value, set_admin_token, token_digest
from app.repositories import app_channel_repository from app.repositories import app_channel_repository, license_repository
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
from app.services.admin_config_service import ( from app.services.admin_config_service import (
PUBLISH_MAX_FIELDS, PUBLISH_MAX_FIELDS,
@@ -18,6 +19,7 @@ from app.services.admin_config_service import (
crash_report_test_values, crash_report_test_values,
crash_report_test_text, crash_report_test_text,
normalize_executable_name, normalize_executable_name,
platform_executable_path,
public_api_base_url, public_api_base_url,
) )
from app.services.common_service import validate_channel_code 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"]) 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") @router.get("/admin/auth/check")
def admin_auth_check(auth=Depends(admin_auth)): def admin_auth_check(auth=Depends(admin_auth)):
return {"code": 0, "msg": "管理员令牌有效"} 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} 不存在"}) raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
defaults = default_client_config_values(request) 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 = { config = {
"app_id": app_id, "app_id": app_id,
"app_name": app_row["app_name"], "app_name": app_row["app_name"],
@@ -91,7 +125,7 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
"current_version": current_version, "current_version": current_version,
"client_protocol": str(client_protocol), "client_protocol": str(client_protocol),
"launch_token": defaults["launch_token"], "launch_token": defaults["launch_token"],
"license_key": body.license_key.strip(), "license_key": license_key,
"api_base_url": api_base_url, "api_base_url": api_base_url,
"client_token": defaults["client_token"], "client_token": defaults["client_token"],
"request_timeout_ms": defaults["request_timeout_ms"], "request_timeout_ms": defaults["request_timeout_ms"],
@@ -110,6 +144,7 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
return { return {
"config": config, "config": config,
"json_text": json.dumps(config, ensure_ascii=False, indent=2), "json_text": json.dumps(config, ensure_ascii=False, indent=2),
"license_warning": license_warning,
"crash_test_config": crash_test_config, "crash_test_config": crash_test_config,
"crash_test_text": crash_report_test_text(api_base_url), "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 from app.services.signing_service import canonical_manifest_bytes, sign_manifest, sign_policy
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows") TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64") TARGET_ARCH = os.getenv("TARGET_ARCH", "x64").strip() or "x64"
SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1") SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["admin-version"]) 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 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_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64") TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64").strip() or "x64"
SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1") SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["client-update"]) 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 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: def set_license_status(license_id: str, status: str) -> int:
conn = db.get_conn() conn = db.get_conn()
cur = conn.execute( 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("/") PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN") 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").strip().lower() or "windows"
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows") TARGET_ARCH = os.getenv("TARGET_ARCH", "x64").strip() or "x64"
TARGET_ARCH = os.getenv("TARGET_ARCH", "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_REQUEST_BYTES = int(os.getenv("PUBLISH_MAX_REQUEST_MB", "4096")) * 1024 * 1024
PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000")) PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100))) 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("/") path = RELEASE_MAIN_EXECUTABLE.strip().replace(chr(92), "/").strip("/")
if path.casefold().startswith("bin/"): if path.casefold().startswith("bin/"):
path = path[4:] path = path[4:]
return path or "SimCAE.exe" return platform_executable_path(path, "SimCAE")
def default_client_config_values(request: Request) -> dict: 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"), "request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
"temp_folder": os.getenv("CLIENT_TEMP_FOLDER", "update_temp"), "temp_folder": os.getenv("CLIENT_TEMP_FOLDER", "update_temp"),
"install_root": os.getenv("CLIENT_INSTALL_ROOT", ".."), "install_root": os.getenv("CLIENT_INSTALL_ROOT", ".."),
"main_executable": os.getenv("CLIENT_MAIN_EXECUTABLE", default_client_main_executable()), "main_executable": platform_executable_path(
"launcher_executable": os.getenv("CLIENT_LAUNCHER_EXECUTABLE", "Launcher.exe"), os.getenv("CLIENT_MAIN_EXECUTABLE", ""),
"updater_executable": os.getenv("CLIENT_UPDATER_EXECUTABLE", "Updater.exe"), default_client_main_executable(),
"bootstrap_executable": os.getenv("CLIENT_BOOTSTRAP_EXECUTABLE", "Bootstrap.exe"), ),
"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"), "health_check_timeout_ms": os.getenv("CLIENT_HEALTH_CHECK_TIMEOUT_MS", "15000"),
"platform": TARGET_PLATFORM, "platform": TARGET_PLATFORM,
"arch": TARGET_ARCH, "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 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 = configured_path("UPLOAD_SPOOL_DIR", "upload_spool")
UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True)
tempfile.tempdir = str(UPLOAD_SPOOL_DIR) 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 被占用,可以改成其他端口。 | | `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP: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` 完全一致。 | | `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | | `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | | `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` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | | `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | | `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | | `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`。只有更换签名体系时才需要改。 | | `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | | `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | | `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
@@ -394,16 +394,27 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
进入后台后,“发布新版本”支持两种方式: 进入后台后,“发布新版本”支持两种方式:
```text ```text
1. 选择软件发布根目录,例如 SimCAE 目录目录内应包含 bin/SimCAE.exe 1. 选择软件发布根目录,例如 SimCAE 目录。Windows 目录内应包含 bin/SimCAE.exeLinux 目录内应包含 bin/SimCAE
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar 2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
``` ```
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: 压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
```text ```text
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe 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`。 `.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
## 七、常用命令 ## 七、常用命令