feat: 优化发布清单和安装结构自动识别

This commit is contained in:
2026-07-15 06:33:33 +00:00
parent 0548692db0
commit 4df439b5ce
28 changed files with 1702 additions and 198 deletions
+14 -2
View File
@@ -5,7 +5,7 @@ SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0
SERVER_PORT=8000
# 如果后台经过域名、反向代理或端口映射访问,可以在这里固定客户端 API 地址。
# 留空时,管理页会按当前访问后台的地址自动生成 api_base_url。
# 留空时,管理页会按当前访问后台的地址自动生成 qrc 服务端配置里的 api_base_url。
PUBLIC_API_BASE_URL=
# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。
@@ -44,9 +44,21 @@ UPLOAD_SPACE_RESERVE_MB=256
# 管理页“客户端配置生成”会自动把这个值写入 app_config.json 的 client_token。
CLIENT_API_TOKEN=SimCAEClientToken2026
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在管理员令牌输入这个值,正式部署建议修改
# 管理后台兼容令牌。新后台默认使用用户名/密码登录 + JWT
# 这个值仍用于旧脚本兼容、JWT_SECRET 未设置时的默认签名密钥,以及 CRASH_ADMIN_TOKEN 为空时的崩溃报告管理兜底令牌。
ADMIN_TOKEN=SimCAEAdminToken2026
# 管理后台初始用户。首次启动且数据库里没有管理员用户时,会自动创建这个账号。
# 登录后台时使用 ADMIN_USERNAME / ADMIN_PASSWORD。正式部署建议修改。
ADMIN_USERNAME=admin
ADMIN_PASSWORD=SimCAEAdminToken2026
ADMIN_DISPLAY_NAME=超级管理员
# 管理后台 JWT 配置。正式部署建议改成随机长字符串,并长期保持不变。
ADMIN_JWT_SECRET=SimCAE_Admin_JWT_2026_ChangeMe
ADMIN_ACCESS_TOKEN_EXPIRE_MIN=120
ADMIN_REFRESH_TOKEN_EXPIRE_DAYS=7
# License Key 加密保存密钥。后台授权列表需要用它解密显示 License Key。
# 已给默认值,可直接试跑;正式部署建议修改,并且部署后长期保持不变。
# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。
+10 -8
View File
@@ -200,10 +200,12 @@ MinIO 控制台: http://你的服务器IP:9001/
| --- | --- | --- | --- |
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成 qrc 服务端配置 `server_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`Linux 示例:`bin/SimCAE`。 |
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 必填 | 可以 | 管理后台初始用户名和密码。首次启动且数据库里没有管理员用户时,会自动创建这个账号。网页登录时输入这组账号密码。 |
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台兼容令牌。新后台默认不用它登录;它仍用于旧脚本兼容、JWT_SECRET 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
@@ -263,15 +265,15 @@ MinIO 控制台: http://你的服务器IP:9001/
3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
4. 打开“客户端配置生成”
5. 点击“生成配套配置”
6. 点击“复制配置”
7. 粘贴到客户端 bin/config/app_config.json
6. 点击“复制配置”,粘贴到客户端 `bin/config/app_config.json`
7. 点击“复制 qrc 配置”,粘贴到客户端源码 `config/server_config.json`
8. 重新编译 Launcher / Updater / Bootstrap,让 `api_base_url` 通过 qrc 编进程序
```
客户端 `config/app_config.json` 至少要和服务端保持这个值一致:
客户端 `config/app_config.json` 至少要和服务端保持这个值一致:
```json
{
"api_base_url": "http://你的服务器IP:8000",
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
}
```
@@ -288,7 +290,7 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
"client_token": "SimCAEClientToken2026"
```
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。它现在位于客户端源码 `config/server_config.json`,并通过 qrc 编译进程序,不再写入客户端 `app_config.json` 或注册表。
## 六、发布新版本
@@ -438,4 +440,4 @@ MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
默认用户名、默认密码、默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
+15 -11
View File
@@ -24,7 +24,7 @@
- 目前不是把某个 GitHub 后端模板整套照搬进项目,而是采用 FastAPI 作为成熟后端框架,并按成熟 FastAPI 后台项目的常见结构做工程化拆分。
- 管理后台前端已经基于 pure-admin-thin 这类成熟后台模板改造。
- 管理员鉴权当前仍是项目自有令牌机制,尚未整套接入 FastAPI Users/JWT/RBAC。后续生产化安全加固时,可以继续引入成熟认证权限组件
- 管理员鉴权已经从单一 `ADMIN_TOKEN` 升级为用户名/密码登录、Argon2 密码哈希、JWT access/refresh token、RBAC 权限检查和管理员用户管理页面。`ADMIN_TOKEN` 仍作为旧脚本兼容令牌和崩溃报告管理兜底令牌保留
## 当前已落地
@@ -35,6 +35,7 @@ app/
api/
router.py
routes/
admin_auth.py
admin_license.py
admin_policy.py
admin_version.py
@@ -53,6 +54,7 @@ app/
audit.py
db/
repositories/
admin_user_repository.py
app_channel_repository.py
crash_report_repository.py
device_repository.py
@@ -63,6 +65,7 @@ app/
client_update_repository.py
publish_repository.py
schemas/
admin_auth.py
license.py
policy.py
version.py
@@ -84,7 +87,7 @@ app/
当前已经从 `main.py` 抽出的通用能力:
- `app/core/config.py`:统一读取 `.env`、路径配置和基础 settings。
- `app/core/security.py`后台令牌校验、令牌摘要、`.env` 写入工具。
- `app/core/security.py`管理员 JWT、Argon2 密码哈希、RBAC 权限检查、兼容令牌、令牌摘要、`.env` 写入工具。
- `app/core/audit.py`:管理后台写操作审计中间件。
- `app/api/routes/frontend.py`:管理后台静态页面、favicon、platform-config、health 路由。
- `app/api/routes/admin_license.py`:License 创建、列表、禁用/启用、软删除接口。
@@ -94,7 +97,8 @@ app/
- `app/api/routes/admin_logs.py`:升级日志、下载日志、管理员审计日志的列表、删除和清空接口。
- `app/api/routes/admin_crash_report.py`:崩溃报告管理列表和附件下载接口。
- `app/api/routes/admin_device.py`:设备列表和设备禁用/恢复接口。
- `app/api/routes/admin_config.py`:管理员令牌校验/变更、运行时配置、客户端配置生成接口。
- `app/api/routes/admin_auth.py`:用户名密码登录、JWT 刷新、登录检查、修改密码、管理员用户列表、创建、角色编辑、禁用/启用和重置密码接口。
- `app/api/routes/admin_config.py`:运行时配置、客户端配置生成接口,以及旧兼容令牌变更接口。
- `app/api/routes/admin_publish.py`:管理端发布新版本接口,包含发布锁和管理员鉴权。
- `app/api/routes/client_update.py`:客户端设备登记、更新检测、下载链接、Manifest、下载日志和升级结果上报接口。
- `app/api/routes/crash_api.py`:崩溃报告健康检查、报告上传/下载和符号包上传接口。
@@ -116,14 +120,14 @@ app/
后续不建议一次性重写全部后端,而是按下面顺序迁移:
1. 接入成熟用户认证模块,例如 FastAPI Users。
2. `ADMIN_TOKEN` 替换成管理员用户名/密码登录、JWT、refresh token
3. 增加用户、角色、权限、菜单权限、API 权限表
4. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/`
5. 继续请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型
6. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰
7. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层
8. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic
1. 继续接入更完整的成熟用户认证模块,例如 FastAPI Users,替换当前轻量用户表
2.现有权限点继续细化,例如 `version:publish``license:create``audit:read`
3. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/`
4. 继续将请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型
5. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰
6. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层
7. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic
8. 增加登录失败审计、登录限流、会话管理和更细粒度 Token 轮换
9. 保持客户端接口 `/api/v1/...` 尽量兼容,避免客户端 SDK 重编。
## 为什么不是直接删除 main.py
+14 -5
View File
@@ -1,4 +1,4 @@
import { getToken } from "@/utils/auth";
import { formatToken, getToken } from "@/utils/auth";
export class AdminApiError extends Error {
status: number;
@@ -13,7 +13,17 @@ export class AdminApiError extends Error {
}
export function adminToken() {
return getToken()?.accessToken || localStorage.getItem("admin_token") || "";
return getToken()?.accessToken || "";
}
function applyAdminAuth(headers: Headers) {
const token = adminToken();
if (token) {
headers.set("Authorization", formatToken(token));
return;
}
const legacyToken = localStorage.getItem("admin_token") || "";
if (legacyToken) headers.set("X-Admin-Token", legacyToken);
}
export function friendlyError(error: unknown) {
@@ -37,8 +47,7 @@ export async function adminRequest<T = any>(
} = {}
): Promise<T> {
const headers = new Headers(options.headers || {});
const token = adminToken();
if (token) headers.set("X-Admin-Token", token);
applyAdminAuth(headers);
let body = options.body;
if (options.json !== undefined) {
@@ -100,7 +109,7 @@ export async function downloadAdminFile(
} = {}
) {
const headers = new Headers(options.headers || {});
headers.set("X-Admin-Token", adminToken());
applyAdminAuth(headers);
let body = options.body;
if (options.json !== undefined) {
+35 -32
View File
@@ -11,12 +11,13 @@ import { useLayout } from "@/layout/hooks/useLayout";
import { initRouter, getTopMenu } from "@/router/utils";
import { bg, avatar, illustration } from "./utils/static";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { setToken } from "@/utils/auth";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import dayIcon from "@/assets/svg/day.svg?component";
import darkIcon from "@/assets/svg/dark.svg?component";
import Lock from "~icons/ri/lock-fill";
import User from "~icons/ri/user-3-fill";
import { useUserStoreHook } from "@/store/modules/user";
defineOptions({
name: "Login"
@@ -33,50 +34,32 @@ initStorage();
const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange();
dataThemeChange(overallStyle.value);
const { title } = useNav();
const userStore = useUserStoreHook();
const ruleForm = reactive({
adminToken: localStorage.getItem("admin_token") || ""
username: localStorage.getItem("admin_username") || "admin",
password: ""
});
async function checkAdminToken(adminToken: string) {
const response = await fetch("/admin/auth/check", {
cache: "no-store",
headers: {
"X-Admin-Token": adminToken
}
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || "管理员令牌验证失败");
}
}
const onLogin = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate(async valid => {
if (!valid) return;
loading.value = true;
try {
const adminToken = ruleForm.adminToken.trim();
await checkAdminToken(adminToken);
localStorage.setItem("admin_token", adminToken);
setToken({
accessToken: adminToken,
refreshToken: adminToken,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
avatar: "",
username: "admin",
nickname: "管理员",
roles: ["admin"],
permissions: ["*:*:*"]
const username = ruleForm.username.trim();
await userStore.loginByUsername({
username,
password: ruleForm.password
});
localStorage.setItem("admin_username", username);
await initRouter();
disabled.value = true;
await router.push(getTopMenu(true).path);
message("登录成功", { type: "success" });
} catch (error) {
console.error(error);
message("管理员令牌错误或服务端不可访问", { type: "error" });
message("用户名、密码错误或服务端不可访问", { type: "error" });
} finally {
loading.value = false;
disabled.value = false;
@@ -126,20 +109,40 @@ useEventListener(document, "keydown", ({ code }) => {
<el-form ref="ruleFormRef" :model="ruleForm" size="large">
<Motion :delay="100">
<el-form-item
prop="adminToken"
prop="username"
:rules="[
{
required: true,
message: '请输入管理员令牌',
message: '请输入用户名',
trigger: 'blur'
}
]"
>
<el-input
v-model="ruleForm.adminToken"
v-model="ruleForm.username"
clearable
placeholder="用户名"
:prefix-icon="useRenderIcon(User)"
/>
</el-form-item>
</Motion>
<Motion :delay="180">
<el-form-item
prop="password"
:rules="[
{
required: true,
message: '请输入密码',
trigger: 'blur'
}
]"
>
<el-input
v-model="ruleForm.password"
clearable
show-password
placeholder="管理员令牌"
placeholder="密码"
:prefix-icon="useRenderIcon(Lock)"
/>
</el-form-item>
+727 -52
View File
@@ -7,11 +7,22 @@ import {
downloadAdminFile,
friendlyError
} from "@/api/simcae";
import { removeToken } from "@/utils/auth";
defineOptions({
name: "SimCAEConsole"
});
type UploadEntry = { file: File; path: string };
type PublishManifestRule = {
line: number;
raw: string;
pattern: string;
exclude: boolean;
};
const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
const activeTab = ref("overview");
const loading = ref(false);
const publishing = ref(false);
@@ -24,9 +35,13 @@ const crashReports = ref<any[]>([]);
const upgradeLogs = ref<any[]>([]);
const downloadLogs = ref<any[]>([]);
const auditLogs = ref<any[]>([]);
const adminUsers = ref<any[]>([]);
const adminRoleOptions = ref<any[]>([]);
const currentAdminUsername = ref("");
const currentAppId = ref("");
const createdLicenseKey = ref("");
const generatedClientConfig = ref("");
const generatedServerConfig = ref("");
const generatedCrashTestInfo = ref("");
const licenseConfigWarning = ref("");
const runtime = reactive<any>({});
@@ -77,12 +92,37 @@ const licenseForm = reactive({
});
const configForm = reactive({
api_base_url: "",
install_root: "..",
main_executable: "SimCAE.exe",
license_key: ""
});
const securityForm = reactive({ next: "", confirm: "" });
const securityForm = reactive({ current: "", next: "", confirm: "" });
const adminUserForm = reactive({
username: "",
password: "",
display_name: "",
roles: ["auditor"]
});
const adminUserEditVisible = ref(false);
const adminUserEditForm = reactive({
username: "",
display_name: "",
roles: [] as string[]
});
const adminUserPasswordVisible = ref(false);
const adminUserPasswordForm = reactive({
username: "",
new_password: ""
});
const rawFiles = ref<File[]>([]);
const archiveFile = ref<File | null>(null);
const selectedReleaseRoot = ref("");
const releaseManifestName = ref("");
const releaseManifestText = ref("");
const releaseManifestError = ref("");
const installRootDetectMessage = ref("");
const installRootDialogVisible = ref(false);
const installRootDialogReason = ref("");
const enabledChannels = computed(() =>
channels.value.filter(item => item.enabled)
@@ -108,7 +148,145 @@ const currentPolicyDisabledText = computed(() => {
return disabled.length ? disabled.join(", ") : "无";
});
const uploadEntries = computed(() => {
const releaseManifestRules = computed(() =>
parseReleaseManifest(releaseManifestText.value, selectedReleaseRoot.value)
);
const uploadEntries = computed<UploadEntry[]>(() => {
if (!rawFiles.value.length || !releaseManifestName.value || releaseManifestError.value) {
return [];
}
const rules = releaseManifestRules.value;
if (!rules.length) return [];
const hasIncludeRules = rules.some(rule => !rule.exclude);
return rawFiles.value
.map(file => ({ file, path: relativePathForFile(file) }))
.filter(item => item.path && !isReleaseManifestFile(item.path))
.filter(item => {
let selected = !hasIncludeRules;
for (const rule of rules) {
if (manifestRuleMatches(rule.pattern, item.path)) {
selected = !rule.exclude;
}
}
return selected && isSafePublishPath(item.path);
});
});
const publishSummary = computed(() => {
if (publishForm.mode === "archive") {
return archiveFile.value
? `已选择压缩包:${archiveFile.value.name}`
: "未选择压缩包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar";
}
if (!rawFiles.value.length) {
return "未选择文件夹,请选择干净的 Release 发布根目录";
}
if (!releaseManifestName.value) {
return `未找到发布清单,请在所选目录根部放置 ${RELEASE_MANIFEST_NAMES.join(" 或 ")}`;
}
if (releaseManifestError.value) {
return releaseManifestError.value;
}
if (!uploadEntries.value.length) {
return "发布清单没有匹配到有效文件,请检查清单路径或是否被安全规则过滤";
}
const preview = uploadEntries.value
.slice(0, 6)
.map(item => item.path)
.join("、");
return `已读取 ${releaseManifestName.value},将发布 ${uploadEntries.value.length} 个文件:${preview}${
uploadEntries.value.length > 6 ? " ..." : ""
}`;
});
const expectedReleaseMainExecutable = computed(() =>
releaseMainExecutableForInstall(configForm.install_root, configForm.main_executable)
);
function rawPathForFile(file: File) {
const raw = ((file as any).webkitRelativePath || file.name) as string;
return raw.replaceAll("\\", "/");
}
function relativePathForFile(file: File) {
const raw = rawPathForFile(file);
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
return parts.join("/");
}
function detectSelectedRoot(files: File[]) {
const first = files[0];
if (!first || !(first as any).webkitRelativePath) return "";
const parts = rawPathForFile(first).split("/").filter(Boolean);
return parts.length > 1 ? parts[0] : "";
}
function normalizePublishPath(value: string) {
let normalized = value.replaceAll("\\", "/").trim();
while (normalized.startsWith("./")) normalized = normalized.slice(2);
normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
return normalized;
}
function normalizeManifestPattern(value: string, rootName: string) {
let pattern = normalizePublishPath(value);
const root = normalizePublishPath(rootName);
if (root) {
if (pattern === root) return "*";
if (pattern.startsWith(`${root}/`)) {
pattern = pattern.slice(root.length + 1);
}
}
return pattern || "*";
}
function parseReleaseManifest(text: string, rootName: string): PublishManifestRule[] {
return text
.split(/\r?\n/)
.map((raw, index) => ({ raw, line: index + 1, trimmed: raw.trim() }))
.filter(item => item.trimmed && !item.trimmed.startsWith("#"))
.map(item => {
const exclude = item.trimmed.startsWith("!");
const body = exclude ? item.trimmed.slice(1).trim() : item.trimmed;
return {
line: item.line,
raw: item.raw,
pattern: normalizeManifestPattern(body, rootName),
exclude
};
})
.filter(rule => rule.pattern);
}
function escapeRegExp(value: string) {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function manifestRuleMatches(pattern: string, filePath: string) {
const normalizedPattern = normalizePublishPath(pattern);
const normalizedPath = normalizePublishPath(filePath);
if (normalizedPattern === "*") return true;
if (normalizedPattern.endsWith("/*")) {
const prefix = normalizedPattern.slice(0, -2);
return normalizedPath.startsWith(`${prefix}/`);
}
if (!normalizedPattern.includes("*")) {
return normalizedPath === normalizedPattern;
}
const regex = new RegExp(
`^${normalizedPattern.split("*").map(escapeRegExp).join(".*")}$`
);
return regex.test(normalizedPath);
}
function isReleaseManifestFile(path: string) {
const normalized = normalizePublishPath(path).toLowerCase();
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
}
function isSafePublishPath(itemPath: string) {
const protectedFiles = new Set([
"client.ini",
"bootstrap.exe",
@@ -117,10 +295,7 @@ const uploadEntries = computed(() => {
"config/client_identity.dat",
"config/version_policy.dat"
]);
return rawFiles.value
.map(file => ({ file, path: relativePathForFile(file) }))
.filter(item => {
const path = item.path.toLowerCase();
const path = normalizePublishPath(itemPath).toLowerCase();
const parts = path.split("/");
const blockedDirectory = parts
.slice(0, -1)
@@ -145,37 +320,133 @@ const uploadEntries = computed(() => {
!path.endsWith(".ilk") &&
!path.endsWith(".obj")
);
});
});
}
const publishSummary = computed(() => {
if (publishForm.mode === "archive") {
return archiveFile.value
? `已选择压缩包:${archiveFile.value.name}`
: "未选择压缩包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar";
function normalizeExecutableName(value: string) {
const normalized = normalizePublishPath(value || "SimCAE.exe");
if (!normalized) return "SimCAE.exe";
const parts = normalized.split("/");
const leaf = parts[parts.length - 1];
if (!leaf.includes(".")) {
parts[parts.length - 1] = `${leaf}.exe`;
}
if (!rawFiles.value.length) {
return "未选择文件夹,请选择干净的 Release 发布根目录";
}
if (!uploadEntries.value.length) {
return "已选择文件夹,但有效发布文件为空,请检查是否选到了 build/update/debug 等运行目录";
}
const preview = uploadEntries.value
.slice(0, 6)
.map(item => item.path)
.join("、");
return `已选择 ${uploadEntries.value.length} 个有效文件:${preview}${
uploadEntries.value.length > 6 ? " ..." : ""
}`;
});
function relativePathForFile(file: File) {
const raw = ((file as any).webkitRelativePath || file.name) as string;
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
return parts.join("/");
}
function releaseMainExecutableForInstall(installRoot: string, mainExecutable: string) {
const root = (installRoot || "..").trim();
const main = normalizeExecutableName(mainExecutable);
if (root === ".") return main;
if (main.toLowerCase().startsWith("bin/")) return main;
return `bin/${main}`;
}
function executableNameCandidates(value: string, fallback: string) {
const leaf =
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback;
const lower = leaf.toLowerCase();
const candidates = new Set([lower]);
if (lower.endsWith(".exe")) {
candidates.add(lower.slice(0, -4));
} else {
candidates.add(`${lower}.exe`);
}
return candidates;
}
function executableLeafName(value: string, fallback: string) {
return (
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback
);
}
function launcherDisplayName() {
return executableLeafName(
runtime?.default_client_config?.launcher_executable,
"Launcher.exe"
);
}
function dirname(path: string) {
const parts = normalizePublishPath(path).split("/").filter(Boolean);
parts.pop();
return parts.join("/");
}
function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
return entries
.map(item => normalizePublishPath(item.path))
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
}
function installRootDescription() {
const launcher = launcherDisplayName();
return configForm.install_root === "."
? `${launcher} 在发布根目录/install_root=.`
: `${launcher} 在发布根目录/bin/install_root=..`;
}
function showInstallRootDialog(reason: string) {
installRootDialogReason.value = reason;
installRootDetectMessage.value = reason;
installRootDialogVisible.value = true;
}
function confirmInstallRootDialog() {
installRootDialogVisible.value = false;
installRootDetectMessage.value =
`已手动确认:${installRootDescription()};当前发布包必须包含 ${expectedReleaseMainExecutable.value}`;
}
function autoDetectInstallRootFromPublishEntries() {
installRootDetectMessage.value = "";
installRootDialogVisible.value = false;
installRootDialogReason.value = "";
const entries = uploadEntries.value;
if (!entries.length) return;
const launcherName = launcherDisplayName();
const launcherPath = findExecutablePath(
entries,
executableNameCandidates(launcherName, "Launcher.exe")
);
const mainPath = findExecutablePath(
entries,
executableNameCandidates(configForm.main_executable, "SimCAE.exe")
);
if (!launcherPath) {
showInstallRootDialog(`未在根据发布清单将要发布的文件里找到 ${launcherName},请确认安装结构。`);
return;
}
const launcherDir = dirname(launcherPath);
if (!launcherDir) {
configForm.install_root = ".";
installRootDetectMessage.value =
`已根据发布清单判断:${launcherName} 在发布根目录/install_root=.;当前发布包必须包含 ${expectedReleaseMainExecutable.value}`;
} else if (launcherDir.toLowerCase() === "bin") {
configForm.install_root = "..";
installRootDetectMessage.value =
`已根据发布清单判断:${launcherName} 在发布根目录/bin/install_root=..;当前发布包必须包含 ${expectedReleaseMainExecutable.value}`;
} else {
showInstallRootDialog(
`发布清单里的 ${launcherName} 位于 ${launcherDir}/,当前只支持 ${launcherName} 在发布根目录/ 或 发布根目录/bin/,请确认安装结构。`
);
return;
}
if (mainPath && dirname(mainPath).toLowerCase() !== dirname(expectedReleaseMainExecutable.value).toLowerCase()) {
installRootDetectMessage.value += `;注意:根据发布清单匹配到的主程序位于 ${mainPath},请确认主程序文件名和安装结构是否一致。`;
}
}
function formatBytes(bytes: number) {
const n = Number(bytes) || 0;
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
@@ -194,6 +465,10 @@ function handleError(prefix: string, error: unknown) {
ElMessage.error(`${prefix}${friendlyError(error)}`);
}
function roleLabel(role: string) {
return adminRoleOptions.value.find(item => item.value === role)?.label || role;
}
function isLicenseFull(row: any) {
return Number(row?.used_devices || 0) >= Number(row?.max_devices || 0);
}
@@ -206,9 +481,11 @@ async function loadRuntime() {
const data = await adminRequest<any>("/admin/runtime-config");
Object.assign(runtime, data || {});
configForm.api_base_url =
data?.api_base_url || data?.default_client_config?.api_base_url || location.origin;
data?.api_base_url || location.origin;
configForm.main_executable =
data?.default_client_config?.main_executable || configForm.main_executable;
configForm.install_root =
data?.default_client_config?.install_root || configForm.install_root;
}
async function loadApps() {
@@ -282,12 +559,47 @@ async function saveChannel() {
}
}
function onDirectoryFiles(event: Event) {
rawFiles.value = Array.from((event.target as HTMLInputElement).files || []);
function resetReleaseManifest() {
selectedReleaseRoot.value = "";
releaseManifestName.value = "";
releaseManifestText.value = "";
releaseManifestError.value = "";
installRootDetectMessage.value = "";
installRootDialogVisible.value = false;
installRootDialogReason.value = "";
}
async function onDirectoryFiles(event: Event) {
const files = Array.from((event.target as HTMLInputElement).files || []);
rawFiles.value = files;
archiveFile.value = null;
resetReleaseManifest();
if (!files.length) return;
selectedReleaseRoot.value = detectSelectedRoot(files);
const manifest = files.find(file => isReleaseManifestFile(relativePathForFile(file)));
if (!manifest) {
releaseManifestError.value = `未找到发布清单,请在目录根部放置 ${RELEASE_MANIFEST_NAMES.join(" 或 ")}`;
return;
}
releaseManifestName.value = relativePathForFile(manifest);
try {
const text = await manifest.text();
releaseManifestText.value = text;
if (!parseReleaseManifest(text, selectedReleaseRoot.value).length) {
releaseManifestError.value = "发布清单为空,请至少写入一条发布或排除规则";
} else {
autoDetectInstallRootFromPublishEntries();
}
} catch (error) {
console.error("read release manifest error", error);
releaseManifestError.value = "读取发布清单失败,请确认清单是普通文本文件";
}
}
function onArchiveFile(event: Event) {
archiveFile.value = (event.target as HTMLInputElement).files?.[0] || null;
rawFiles.value = [];
resetReleaseManifest();
}
async function publishVersion() {
@@ -302,11 +614,23 @@ async function publishVersion() {
"client_protocol",
String(Math.max(1, Number(publishForm.client_protocol) || 3))
);
formData.append("install_root", configForm.install_root || "..");
formData.append("main_executable", configForm.main_executable || "SimCAE.exe");
if (publishForm.mode === "archive") {
if (!archiveFile.value) return ElMessage.warning("未选择压缩包");
formData.append("archive", archiveFile.value, archiveFile.value.name);
} else {
if (!uploadEntries.value.length) return ElMessage.warning("未选择文件夹");
if (!rawFiles.value.length) return ElMessage.warning("未选择文件夹");
if (!releaseManifestName.value) {
return ElMessage.warning(`未找到发布清单:${RELEASE_MANIFEST_NAMES.join(" 或 ")}`);
}
if (releaseManifestError.value) return ElMessage.warning(releaseManifestError.value);
if (!uploadEntries.value.length) {
return ElMessage.warning("发布清单没有匹配到可发布文件");
}
if (installRootDialogVisible.value) {
return ElMessage.warning("请先确认安装结构");
}
uploadEntries.value.forEach(item => {
formData.append("files", item.file, item.file.name);
formData.append("relative_paths", item.path);
@@ -529,6 +853,7 @@ async function deleteLicense(row: any) {
}
async function generateClientConfig() {
if (installRootDialogVisible.value) return ElMessage.warning("请先确认安装结构");
try {
const result = await adminRequest<any>("/admin/client-config/generate", {
method: "POST",
@@ -539,11 +864,14 @@ async function generateClientConfig() {
client_protocol: publishForm.client_protocol || 3,
license_key: configForm.license_key,
api_base_url: configForm.api_base_url,
install_root: configForm.install_root,
main_executable: configForm.main_executable
}
});
generatedClientConfig.value =
result.json_text || JSON.stringify(result.config || {}, null, 2);
generatedServerConfig.value =
result.server_config_text || JSON.stringify(result.server_config || {}, null, 2);
generatedCrashTestInfo.value =
result.crash_test_text ||
JSON.stringify(result.crash_test_config || {}, null, 2);
@@ -569,6 +897,11 @@ async function copyGeneratedCrashTestInfo() {
ElMessage.success("崩溃报告联调信息已复制");
}
async function copyGeneratedServerConfig() {
await copyText(generatedServerConfig.value);
ElMessage.success("qrc 服务端配置已复制");
}
async function loadDevices() {
if (!currentAppId.value) return;
const data = await adminRequest<any>(
@@ -666,22 +999,129 @@ async function clearLog(endpoint: string, reload: () => Promise<void>) {
}
}
async function changeAdminToken() {
if (securityForm.next.length < 8) return ElMessage.warning("新令牌至少 8 个字符");
if (securityForm.next !== securityForm.confirm) {
return ElMessage.warning("两次输入的新令牌不一致");
async function loadAdminUsers() {
try {
const data = await adminRequest<any>("/admin/user/list");
adminUsers.value = data?.list || [];
adminRoleOptions.value = data?.roles || [];
currentAdminUsername.value = data?.current_username || "";
} catch (error) {
adminUsers.value = [];
adminRoleOptions.value = [];
if ((error as any)?.status !== 403) handleError("加载管理员用户失败", error);
}
}
async function createAdminUser() {
if (!adminUserForm.username.trim()) return ElMessage.warning("请输入用户名");
if (adminUserForm.password.length < 8) return ElMessage.warning("密码至少 8 个字符");
if (!adminUserForm.roles.length) return ElMessage.warning("请至少选择一个角色");
try {
await adminRequest("/admin/user/create", {
method: "POST",
json: adminUserForm
});
ElMessage.success("管理员用户已创建");
adminUserForm.username = "";
adminUserForm.password = "";
adminUserForm.display_name = "";
adminUserForm.roles = ["auditor"];
await loadAdminUsers();
} catch (error) {
handleError("创建管理员用户失败", error);
}
}
function openEditAdminUser(row: any) {
adminUserEditForm.username = row.username;
adminUserEditForm.display_name = row.display_name || row.username;
adminUserEditForm.roles = Array.isArray(row.roles) ? [...row.roles] : [];
adminUserEditVisible.value = true;
}
async function saveAdminUser() {
if (!adminUserEditForm.roles.length) return ElMessage.warning("请至少选择一个角色");
try {
await adminRequest("/admin/user/update", {
method: "POST",
json: adminUserEditForm
});
ElMessage.success("管理员用户已更新");
adminUserEditVisible.value = false;
await loadAdminUsers();
} catch (error) {
handleError("更新管理员用户失败", error);
}
}
async function toggleAdminUser(row: any) {
const nextStatus = row.status === "active" ? "disabled" : "active";
try {
if (nextStatus === "disabled") {
await ElMessageBox.confirm(
`确定禁用管理员 ${row.username} 吗?禁用后该账号不能再登录。`,
"禁用管理员",
{ type: "warning" }
);
}
await adminRequest("/admin/user/status", {
method: "POST",
json: { username: row.username, status: nextStatus }
});
ElMessage.success("管理员状态已更新");
await loadAdminUsers();
} catch (error) {
if (error !== "cancel" && error !== "close") handleError("修改管理员状态失败", error);
}
}
function openResetAdminPassword(row: any) {
adminUserPasswordForm.username = row.username;
adminUserPasswordForm.new_password = "";
adminUserPasswordVisible.value = true;
}
async function resetAdminPassword() {
if (adminUserPasswordForm.new_password.length < 8) {
return ElMessage.warning("新密码至少 8 个字符");
}
try {
await adminRequest("/admin/token/change", {
await adminRequest("/admin/user/password/reset", {
method: "POST",
json: { new_token: securityForm.next }
json: adminUserPasswordForm
});
localStorage.setItem("admin_token", securityForm.next);
ElMessage.success("管理员密码已重置");
adminUserPasswordVisible.value = false;
await loadAdminUsers();
} catch (error) {
handleError("重置管理员密码失败", error);
}
}
async function changeAdminPassword() {
if (!securityForm.current) return ElMessage.warning("请输入当前密码");
if (securityForm.next.length < 8) return ElMessage.warning("新密码至少 8 个字符");
if (securityForm.next !== securityForm.confirm) {
return ElMessage.warning("两次输入的新密码不一致");
}
try {
await adminRequest("/admin/user/password/change", {
method: "POST",
json: {
current_password: securityForm.current,
new_password: securityForm.next
}
});
securityForm.current = "";
securityForm.next = "";
securityForm.confirm = "";
ElMessage.success("管理员令牌已更新,请重新登录确认");
removeToken();
ElMessage.success("密码已更新,请重新登录");
setTimeout(() => {
window.location.href = "/login";
}, 800);
} catch (error) {
handleError("更改令牌失败", error);
handleError("修改密码失败", error);
}
}
@@ -690,6 +1130,7 @@ async function loadAll() {
try {
await loadRuntime();
await loadApps();
await loadAdminUsers();
await loadCrashReports();
await loadLogs();
} finally {
@@ -808,7 +1249,7 @@ onMounted(loadAll);
type="info"
:closable="false"
show-icon
title="发布前请选择 SimCAE 的 Release 发布根目录,或上传已经压缩好的发布包。不要选择 build、debug、update 等运行目录。"
title="发布前请选择 SimCAE 的 Release 发布根目录,或上传已经压缩好的发布包。目录模式会按发布清单决定上传范围。"
/>
<el-form label-position="top" :model="publishForm" class="form-grid">
<el-form-item label="App ID">
@@ -827,10 +1268,36 @@ onMounted(loadAll);
<div class="field-help">不确定时保持默认值只有客户端升级协议变化时才需要调整</div>
</el-form-item>
</el-form>
<el-alert
v-if="installRootDetectMessage"
class="mb"
:type="installRootDialogVisible ? 'warning' : 'success'"
:closable="false"
show-icon
:title="installRootDetectMessage"
/>
<el-radio-group v-model="publishForm.mode">
<el-radio-button label="directory">Release 目录</el-radio-button>
<el-radio-button label="archive">压缩包</el-radio-button>
</el-radio-group>
<div v-if="publishForm.mode === 'directory'" class="manifest-guide">
<div class="manifest-guide-title">目录模式需要在所选目录根部放置 <strong>发布清单.txt</strong>也兼容 <strong>release_manifest.txt</strong></div>
<div class="manifest-guide-rules">
例如你选择的是 test_file 文件夹就在 test_file/发布清单.txt 里写下面这些规则
</div>
<pre># 发布 test_file 文件夹下的所有内容
test_file/*
# 不发布升级运行时目录
!test_file/update/*
# 不发布调试符号文件
!test_file/*.pdb</pre>
<div class="manifest-guide-rules">
普通路径表示要发布前缀 ! 表示排除空行和 # 注释会忽略后面的规则优先也可以省略根目录名例如写 *!update/*!*.pdb
</div>
<div class="field-help">安全兜底仍会过滤 .gitbuilddebugupdateupdate_temp调试文件和客户端本地配置文件</div>
</div>
<div class="upload-box">
<input v-if="publishForm.mode === 'directory'" type="file" webkitdirectory directory multiple @change="onDirectoryFiles" />
<input v-else type="file" accept=".zip,.tar.gz,.tgz,.tar.bz2,.tbz2,.rar" @change="onArchiveFile" />
@@ -944,14 +1411,20 @@ onMounted(loadAll);
<el-card shadow="never" class="mt">
<template #header>客户端配置生成</template>
<el-form label-position="top" :model="configForm" class="form-grid">
<el-form-item label="服务端 API 地址"><el-input v-model="configForm.api_base_url" placeholder="例如 http://192.168.229.128:8000" /></el-form-item>
<el-form-item label="服务端 API 地址">
<el-input v-model="configForm.api_base_url" placeholder="例如 http://192.168.229.128:8000" />
<div class="field-help">这个地址会生成到 qrc 编译配置不会写入客户端 app_config.json</div>
</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>
<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">复制 app_config</el-button></div>
<div class="config-section-title">客户端 app_config.json</div>
<pre class="code-block">{{ generatedClientConfig || "尚未生成客户端配置" }}</pre>
<el-alert class="mt-sm" type="info" :closable="false" show-icon title="下面的 server_config.json 用于编译进客户端 qrc。请在编译 Launcher/Updater 前写入 update-client/config/server_config.json;它不会复制进 app_config.json。" />
<div class="action-row mt-sm"><span class="config-section-title">qrc 服务端配置 server_config.json</span><el-button :disabled="!generatedServerConfig" @click="copyGeneratedServerConfig">复制 qrc 配置</el-button></div>
<pre class="code-block small-code-block">{{ generatedServerConfig || "生成配套配置后显示 qrc 服务端配置" }}</pre>
<el-alert class="mt-sm" type="warning" :closable="false" show-icon title="下面的崩溃报告联调信息只给管理员或测试人员查看,不会复制进客户端 app_config.json,也不要放进客户端配置文件。" />
<div class="action-row mt-sm"><span class="config-section-title">崩溃报告接口联调信息</span><el-button :disabled="!generatedCrashTestInfo" @click="copyGeneratedCrashTestInfo">复制联调信息</el-button></div>
<pre class="code-block">{{ generatedCrashTestInfo || "生成配套配置后显示崩溃报告接口、Token 和大小限制" }}</pre>
@@ -1012,7 +1485,10 @@ onMounted(loadAll);
<el-tab-pane label="审计日志">
<el-button type="danger" plain @click="clearLog('/admin/audit-log/clear', loadAuditLogs)">清空</el-button>
<el-table :data="auditLogs" stripe empty-text="暂无审计日志">
<el-table-column prop="actor_hash" label="管理员指纹" />
<el-table-column prop="actor_username" label="管理员" min-width="130" />
<el-table-column prop="actor_roles" label="角色" min-width="150" />
<el-table-column prop="auth_type" label="认证方式" min-width="110" />
<el-table-column prop="actor_hash" label="指纹" min-width="130" />
<el-table-column label="操作" min-width="220"><template #default="{ row }">{{ row.method }} {{ row.path }}</template></el-table-column>
<el-table-column prop="result" label="结果" />
<el-table-column prop="status_code" label="状态码" />
@@ -1026,14 +1502,155 @@ onMounted(loadAll);
<el-tab-pane label="安全设置" name="security">
<el-card shadow="never">
<template #header>修改当前账号密码</template>
<el-form label-position="top" :model="securityForm" class="form-grid">
<el-form-item label="新管理员令牌"><el-input v-model="securityForm.next" show-password /></el-form-item>
<el-form-item label="确认新令牌"><el-input v-model="securityForm.confirm" show-password /></el-form-item>
<el-form-item label="当前密码"><el-input v-model="securityForm.current" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="securityForm.next" show-password placeholder="至少 8 个字符" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="securityForm.confirm" show-password /></el-form-item>
</el-form>
<el-button type="primary" @click="changeAdminToken">更改管理员令牌</el-button>
<el-button type="primary" @click="changeAdminPassword">修改密码</el-button>
</el-card>
<el-card shadow="never" class="mt">
<template #header>
<div class="card-header-row">
<span>管理员用户</span>
<el-button @click="loadAdminUsers">刷新用户</el-button>
</div>
</template>
<el-alert
class="mb"
type="info"
:closable="false"
show-icon
title="只有具备管理员管理权限的账号可以创建、禁用、分配角色和重置密码。系统会阻止禁用最后一个超级管理员。"
/>
<el-form label-position="top" :model="adminUserForm" class="form-grid">
<el-form-item label="用户名">
<el-input v-model="adminUserForm.username" placeholder="例如 release_admin" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="adminUserForm.display_name" placeholder="例如 发布管理员" />
</el-form-item>
<el-form-item label="初始密码">
<el-input v-model="adminUserForm.password" show-password placeholder="至少 8 个字符" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="adminUserForm.roles" multiple collapse-tags collapse-tags-tooltip>
<el-option
v-for="role in adminRoleOptions"
:key="role.value"
:label="role.label"
:value="role.value"
/>
</el-select>
</el-form-item>
</el-form>
<el-button type="primary" @click="createAdminUser">创建管理员</el-button>
<el-table class="mt" :data="adminUsers" stripe empty-text="暂无管理员用户">
<el-table-column prop="username" label="用户名" min-width="150" />
<el-table-column prop="display_name" label="显示名称" min-width="150" />
<el-table-column label="角色" min-width="230">
<template #default="{ row }">
<div class="role-tags">
<el-tag v-for="role in row.roles" :key="role" size="small">
{{ roleLabel(role) }}
</el-tag>
</div>
</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 prop="last_login_at" label="最近登录" min-width="170" />
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button link type="primary" @click="openEditAdminUser(row)">编辑</el-button>
<el-button link type="primary" @click="openResetAdminPassword(row)">重置密码</el-button>
<el-button
link
:type="row.status === 'active' ? 'danger' : 'success'"
:disabled="row.username === currentAdminUsername && row.status === 'active'"
@click="toggleAdminUser(row)"
>
{{ row.status === "active" ? "禁用" : "启用" }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog
v-model="installRootDialogVisible"
title="确认安装结构"
width="560px"
:close-on-click-modal="false"
>
<el-alert
class="mb"
type="warning"
:closable="false"
show-icon
:title="installRootDialogReason || '页面无法从发布清单自动判断安装结构,请手动确认。'"
/>
<el-radio-group v-model="configForm.install_root" class="install-root-options">
<el-radio-button label="..">{{ launcherDisplayName() }} 在发布根目录/bin/</el-radio-button>
<el-radio-button label=".">{{ launcherDisplayName() }} 在发布根目录/</el-radio-button>
</el-radio-group>
<div class="field-help">
当前选择{{ installRootDescription() }}发布包必须包含 {{ expectedReleaseMainExecutable }}确认后会写入生成的 app_config.json
</div>
<template #footer>
<el-button type="primary" @click="confirmInstallRootDialog">确认</el-button>
</template>
</el-dialog>
<el-dialog v-model="adminUserEditVisible" title="编辑管理员" width="520px">
<el-form label-position="top" :model="adminUserEditForm">
<el-form-item label="用户名">
<el-input v-model="adminUserEditForm.username" disabled />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="adminUserEditForm.display_name" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="adminUserEditForm.roles" multiple collapse-tags collapse-tags-tooltip class="full-width">
<el-option
v-for="role in adminRoleOptions"
:key="role.value"
:label="role.label"
:value="role.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adminUserEditVisible = false">取消</el-button>
<el-button type="primary" @click="saveAdminUser">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="adminUserPasswordVisible" title="重置管理员密码" width="520px">
<el-form label-position="top" :model="adminUserPasswordForm">
<el-form-item label="用户名">
<el-input v-model="adminUserPasswordForm.username" disabled />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="adminUserPasswordForm.new_password" show-password placeholder="至少 8 个字符" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adminUserPasswordVisible = false">取消</el-button>
<el-button type="primary" @click="resetAdminPassword">重置密码</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -1137,6 +1754,23 @@ onMounted(loadAll);
font-size: 12px;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.full-width {
width: 100%;
}
.role-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
:deep(.el-table__row.app-selected-row > td.el-table__cell) {
background-color: var(--el-color-primary-light-9) !important;
}
@@ -1165,6 +1799,43 @@ onMounted(loadAll);
border-radius: 8px;
}
.install-root-options {
margin-bottom: 10px;
}
.manifest-guide {
margin-top: 12px;
padding: 14px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.manifest-guide-title {
margin-bottom: 8px;
color: var(--el-text-color-primary);
font-weight: 500;
}
.manifest-guide-rules {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.manifest-guide pre {
margin: 10px 0 0;
padding: 12px;
overflow: auto;
color: #d1e9ff;
background: #101828;
border-radius: 6px;
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
line-height: 1.6;
}
.mt {
margin-top: 16px;
}
@@ -1209,6 +1880,10 @@ onMounted(loadAll);
white-space: pre-wrap;
}
.small-code-block {
min-height: 96px;
}
@media (max-width: 1200px) {
.metric-grid,
.form-grid {
+2
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from app.api.routes import (
admin_app_channel,
admin_auth,
admin_config,
admin_crash_report,
admin_device,
@@ -20,6 +21,7 @@ def include_api_routes(app: FastAPI):
frontend.mount_admin_assets(app)
crash_api.register_exception_handlers(app)
app.include_router(frontend.router)
app.include_router(admin_auth.router)
app.include_router(admin_policy.router)
app.include_router(admin_version.router)
app.include_router(admin_license.router)
+5 -5
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.core.security import require_permission
from app.repositories import app_channel_repository
from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest
from app.services.common_service import validate_channel_code
@@ -10,12 +10,12 @@ router = APIRouter(tags=["admin-app-channel"])
@router.get("/admin/app/list")
def admin_get_app_list(auth=Depends(admin_auth)):
def admin_get_app_list(auth=Depends(require_permission("app:view"))):
return {"list": app_channel_repository.list_apps()}
@router.post("/admin/app/add")
def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
def admin_add_app(body: AppCreateRequest, auth=Depends(require_permission("app:manage"))):
aid = body.app_id
aname = body.app_name
try:
@@ -26,12 +26,12 @@ def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
@router.get("/admin/channel/list")
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(require_permission("app:view"))):
return {"list": app_channel_repository.list_channels(app_id, include_disabled)}
@router.post("/admin/channel/save")
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(admin_auth)):
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(require_permission("channel:manage"))):
app_id = body.app_id.strip()
code = body.channel_code.strip()
name = body.display_name.strip()
+248
View File
@@ -0,0 +1,248 @@
import json
import re
from datetime import datetime, timezone
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.security import (
AdminPrincipal,
ROLE_NAMES,
ROLE_PERMISSIONS,
admin_auth,
decode_jwt_token,
hash_password,
normalize_roles,
parse_utc_text,
require_permission,
token_digest,
token_response_for_user,
verify_password,
)
from app.repositories import admin_user_repository
from app.schemas.admin_auth import (
AdminLoginRequest,
AdminUserCreateRequest,
AdminUserResetPasswordRequest,
AdminUserStatusRequest,
AdminUserUpdateRequest,
ChangePasswordRequest,
RefreshTokenRequest,
)
router = APIRouter(tags=["admin-auth"])
VALID_ROLES = set(ROLE_PERMISSIONS.keys())
def validate_username(username: str) -> str:
value = username.strip()
if not re.fullmatch(r"[A-Za-z0-9_.@-]{3,64}", value):
raise HTTPException(status_code=400, detail="用户名只能包含字母、数字、下划线、点、@ 和短横线,长度 3-64")
return value
def validate_password(password: str) -> str:
value = password.strip()
if len(value) < 8:
raise HTTPException(status_code=400, detail="密码至少需要 8 个字符")
if len(value) > 128:
raise HTTPException(status_code=400, detail="密码不能超过 128 个字符")
return value
def validate_roles(roles: list[str]) -> list[str]:
result = []
for role in roles:
value = str(role).strip()
if value:
result.append(value)
if not result:
raise HTTPException(status_code=400, detail="至少选择一个角色")
invalid = [role for role in result if role not in VALID_ROLES]
if invalid:
raise HTTPException(status_code=400, detail=f"未知角色:{', '.join(invalid)}")
return result
def public_user(row) -> dict:
return {
"id": row["id"],
"username": row["username"],
"display_name": row["display_name"] or row["username"],
"roles": normalize_roles(row["roles"]),
"status": row["status"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"last_login_at": row["last_login_at"],
}
def role_options() -> list[dict]:
return [
{
"value": role,
"label": ROLE_NAMES.get(role, role),
"permissions": permissions,
}
for role, permissions in ROLE_PERMISSIONS.items()
]
@router.post("/login")
def login(body: AdminLoginRequest, request: Request):
username = body.username.strip()
password = body.password
row = admin_user_repository.get_user(username)
if not row or row["status"] != "active" or not verify_password(password, row["password_hash"]):
raise HTTPException(status_code=401, detail="用户名或密码错误")
admin_user_repository.mark_login(username)
return token_response_for_user(row, request)
@router.post("/refresh-token")
def refresh_token(body: RefreshTokenRequest, request: Request):
payload = decode_jwt_token(body.refreshToken.strip(), "refresh")
token_id = str(payload.get("jti") or "")
username = str(payload.get("sub") or "")
row = admin_user_repository.get_refresh_token(token_id)
if not row or row["username"] != username or row["revoked_at"]:
raise HTTPException(status_code=401, detail="刷新令牌无效")
if row["token_hash"] != token_digest(body.refreshToken.strip()):
raise HTTPException(status_code=401, detail="刷新令牌无效")
if parse_utc_text(row["expires_at"]) <= datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="刷新令牌已过期")
user_row = admin_user_repository.get_user(username)
if not user_row or user_row["status"] != "active":
raise HTTPException(status_code=401, detail="管理员账号不存在或已禁用")
admin_user_repository.revoke_refresh_token(token_id)
return token_response_for_user(user_row, request)
@router.get("/admin/auth/check")
def admin_auth_check(principal: AdminPrincipal = Depends(admin_auth)):
return {
"code": 0,
"msg": "管理员登录凭证有效",
"username": principal.username,
"nickname": principal.display_name,
"roles": principal.roles,
"permissions": principal.permissions,
"auth_type": principal.auth_type,
}
@router.get("/admin/user/list")
def admin_user_list(principal: AdminPrincipal = Depends(require_permission("admin:manage"))):
return {
"code": 0,
"list": [public_user(row) for row in admin_user_repository.list_users()],
"roles": role_options(),
"current_username": principal.username,
}
@router.post("/admin/user/create")
def admin_user_create(
body: AdminUserCreateRequest,
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
):
username = validate_username(body.username)
password = validate_password(body.password)
roles = validate_roles(body.roles)
display_name = body.display_name.strip() or username
try:
admin_user_repository.create_user(
username,
hash_password(password),
display_name,
json.dumps(roles, ensure_ascii=False),
"active",
)
except IntegrityError:
raise HTTPException(status_code=409, detail="用户名已存在")
return {"code": 0, "msg": "管理员用户已创建"}
@router.post("/admin/user/update")
def admin_user_update(
body: AdminUserUpdateRequest,
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
):
username = validate_username(body.username)
row = admin_user_repository.get_user(username)
if not row:
raise HTTPException(status_code=404, detail="管理员用户不存在")
roles = validate_roles(body.roles)
old_roles = normalize_roles(row["roles"])
if "super_admin" in old_roles and "super_admin" not in roles:
active_super_admins = admin_user_repository.count_active_super_admins()
if row["status"] == "active" and active_super_admins <= 1:
raise HTTPException(status_code=400, detail="不能移除最后一个可用超级管理员")
display_name = body.display_name.strip() or username
admin_user_repository.update_user_profile(
username,
display_name,
json.dumps(roles, ensure_ascii=False),
)
return {"code": 0, "msg": "管理员用户已更新"}
@router.post("/admin/user/status")
def admin_user_status(
body: AdminUserStatusRequest,
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
):
username = validate_username(body.username)
status = body.status.strip()
if status not in {"active", "disabled"}:
raise HTTPException(status_code=400, detail="状态只能是 active 或 disabled")
row = admin_user_repository.get_user(username)
if not row:
raise HTTPException(status_code=404, detail="管理员用户不存在")
roles = normalize_roles(row["roles"])
if status == "disabled" and "super_admin" in roles:
active_super_admins = admin_user_repository.count_active_super_admins()
if row["status"] == "active" and active_super_admins <= 1:
raise HTTPException(status_code=400, detail="不能禁用最后一个可用超级管理员")
if username == principal.username and status == "disabled":
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
admin_user_repository.set_user_status(username, status)
if status == "disabled":
admin_user_repository.revoke_refresh_tokens_for_user(username)
return {"code": 0, "msg": "管理员用户状态已更新"}
@router.post("/admin/user/password/reset")
def admin_user_reset_password(
body: AdminUserResetPasswordRequest,
principal: AdminPrincipal = Depends(require_permission("admin:manage")),
):
username = validate_username(body.username)
new_password = validate_password(body.new_password)
row = admin_user_repository.get_user(username)
if not row:
raise HTTPException(status_code=404, detail="管理员用户不存在")
admin_user_repository.update_password(username, hash_password(new_password))
admin_user_repository.revoke_refresh_tokens_for_user(username)
return {"code": 0, "msg": "管理员密码已重置"}
@router.post("/admin/user/password/change")
def change_password(
body: ChangePasswordRequest,
principal: AdminPrincipal = Depends(require_permission("admin:access")),
):
if principal.auth_type == "legacy_token":
raise HTTPException(status_code=400, detail="兼容令牌登录不能修改用户密码,请使用用户名密码登录")
new_password = body.new_password.strip()
if len(new_password) < 8:
raise HTTPException(status_code=400, detail="新密码至少需要 8 个字符")
if len(new_password) > 128:
raise HTTPException(status_code=400, detail="新密码不能超过 128 个字符")
row = admin_user_repository.get_user(principal.username)
if not row or not verify_password(body.current_password, row["password_hash"]):
raise HTTPException(status_code=403, detail="当前密码错误")
admin_user_repository.update_password(principal.username, hash_password(new_password))
return {"code": 0, "msg": "密码已更新,请重新登录"}
+21 -12
View File
@@ -5,7 +5,7 @@ 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, token_digest
from app.core.security import persist_env_value, require_permission, 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 (
@@ -53,29 +53,33 @@ def client_config_license_warning(license_key: str, app_id: str, channel: str) -
return ""
@router.get("/admin/auth/check")
def admin_auth_check(auth=Depends(admin_auth)):
return {"code": 0, "msg": "管理员令牌有效"}
def normalize_install_root(value: str, fallback: str = "..") -> str:
normalized = str(value or fallback).strip().replace("\\", "/")
if normalized in ("", "."):
return "."
if normalized == "..":
return ".."
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
@router.post("/admin/token/change")
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(admin_auth)):
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(require_permission("admin:manage"))):
new_token = body.new_token.strip()
if len(new_token) < 8:
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
raise HTTPException(status_code=400, detail="兼容令牌至少需要 8 个字符")
if len(new_token) > 128:
raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符")
raise HTTPException(status_code=400, detail="兼容令牌不能超过 128 个字符")
try:
persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}")
set_admin_token(new_token)
os.environ["ADMIN_TOKEN"] = new_token
return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
return {"code": 0, "msg": f"兼容管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
@router.get("/admin/runtime-config")
def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
def admin_runtime_config(request: Request, auth=Depends(require_permission("config:view"))):
return {
"release_main_executable": RELEASE_MAIN_EXECUTABLE,
"target_platform": TARGET_PLATFORM,
@@ -89,7 +93,7 @@ def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
@router.post("/admin/client-config/generate")
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(admin_auth)):
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(require_permission("config:view"))):
app_id = body.app_id.strip()
channel = body.channel.strip() or "stable"
current_version = body.current_version.strip() or "1.0.0"
@@ -110,6 +114,7 @@ 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)
install_root = normalize_install_root(body.install_root, defaults["install_root"])
main_executable = platform_executable_path(
normalize_executable_name(body.main_executable),
defaults["main_executable"],
@@ -126,12 +131,11 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
"client_protocol": str(client_protocol),
"launch_token": defaults["launch_token"],
"license_key": license_key,
"api_base_url": api_base_url,
"client_token": defaults["client_token"],
"request_timeout_ms": defaults["request_timeout_ms"],
"temp_folder": defaults["temp_folder"],
"device_id": "",
"install_root": defaults["install_root"],
"install_root": install_root,
"main_executable": main_executable,
"launcher_executable": defaults["launcher_executable"],
"updater_executable": defaults["updater_executable"],
@@ -141,10 +145,15 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
"arch": defaults["arch"],
}
crash_test_config = crash_report_test_values(api_base_url)
server_config = {
"api_base_url": api_base_url,
}
return {
"config": config,
"json_text": json.dumps(config, ensure_ascii=False, indent=2),
"license_warning": license_warning,
"server_config": server_config,
"server_config_text": json.dumps(server_config, ensure_ascii=False, indent=2),
"crash_test_config": crash_test_config,
"crash_test_text": crash_report_test_text(api_base_url),
}
+4 -5
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import FileResponse
from app.core.security import admin_auth, token_digest
from app.core.security import AdminPrincipal, require_permission
from app.repositories import crash_report_repository
@@ -11,7 +11,7 @@ router = APIRouter(tags=["admin-crash-report"])
@router.get("/admin/crash-report/list")
def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)):
def admin_crash_report_list(limit: int = 300, auth=Depends(require_permission("crash:view"))):
limit = max(1, min(limit, 1000))
return {"list": crash_report_repository.list_crash_reports(limit)}
@@ -21,8 +21,7 @@ def admin_crash_report_file(
report_id: str,
file_name: str,
request: Request,
X_Admin_Token: str = Header(""),
auth=Depends(admin_auth),
auth: AdminPrincipal = Depends(require_permission("crash:view")),
):
allowed = {
"metadata": "metadata.json",
@@ -49,7 +48,7 @@ def admin_crash_report_file(
crash_report_repository.log_file_access(
report_id,
stored_name,
token_digest(X_Admin_Token)[:16],
auth.actor_hash,
request.client.host if request.client else "",
request.headers.get("user-agent", "")[:300],
)
+3 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.core.security import require_permission
from app.repositories import device_repository
from app.schemas.device import DeviceSetDisabledRequest
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-device"])
@router.get("/admin/device/list")
def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
def admin_device_list(app_id: str = "", auth=Depends(require_permission("device:view"))):
return {"list": device_repository.list_devices(app_id)}
@router.post("/admin/device/set-disabled")
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(admin_auth)):
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(require_permission("device:manage"))):
device_id = body.device_id.strip()
disabled = bool(body.disabled)
reason = body.reason.strip()
+5 -5
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth, token_digest
from app.core.security import require_permission, token_digest
from app.repositories import license_repository
from app.schemas.license import LicenseCreateRequest, LicenseDeleteRequest, LicenseStatusRequest
from app.services.common_service import validate_channel_code
@@ -14,7 +14,7 @@ router = APIRouter(tags=["admin-license"])
@router.post("/admin/license/create")
def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
def admin_license_create(body: LicenseCreateRequest, auth=Depends(require_permission("license:manage"))):
app_id = body.app_id.strip()
channel = body.channel.strip()
customer = body.customer_name.strip()
@@ -46,7 +46,7 @@ def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
@router.get("/admin/license/list")
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(admin_auth)):
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(require_permission("license:view"))):
rows = license_repository.list_licenses(app_id, include_deleted)
result = []
for r in rows:
@@ -59,7 +59,7 @@ def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Dep
@router.post("/admin/license/set-status")
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth)):
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(require_permission("license:manage"))):
status = body.status
license_id = body.license_id
if status not in ("active", "disabled"):
@@ -71,7 +71,7 @@ def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth
@router.post("/admin/license/delete")
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(admin_auth)):
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(require_permission("license:manage"))):
license_id = body.license_id.strip()
if not license_id:
raise HTTPException(status_code=400, detail="license_id 不能为空")
+10 -10
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Body, Depends, HTTPException
from app.core.security import admin_auth
from app.core.security import require_permission
from app.repositories import log_repository
from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-logs"])
@router.get("/admin/report/list")
def admin_get_report_log(auth=Depends(admin_auth)):
def admin_get_report_log(auth=Depends(require_permission("log:view"))):
return {"list": log_repository.list_upgrade_logs()}
@router.post("/admin/report/delete")
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
@@ -25,19 +25,19 @@ def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
@router.post("/admin/report/clear")
def admin_clear_report_logs(auth=Depends(admin_auth)):
def admin_clear_report_logs(auth=Depends(require_permission("log:manage"))):
deleted = log_repository.clear_upgrade_logs()
return {"msg": "升级日志已清空", "deleted": deleted}
@router.get("/admin/download-log/list")
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(admin_auth)):
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(require_permission("log:view"))):
limit = max(1, min(limit, 1000))
return {"list": log_repository.list_download_logs(app_id, limit)}
@router.post("/admin/download-log/delete")
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
@@ -48,19 +48,19 @@ def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
@router.post("/admin/download-log/clear")
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(admin_auth)):
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(require_permission("log:manage"))):
app_id = (body.app_id if body else "").strip()
deleted = log_repository.clear_download_logs(app_id)
return {"msg": "下载日志已清空", "deleted": deleted}
@router.get("/admin/audit-log/list")
def admin_audit_log_list(limit: int = 300, auth=Depends(admin_auth)):
def admin_audit_log_list(limit: int = 300, auth=Depends(require_permission("log:view"))):
return {"list": log_repository.list_audit_logs(max(1, min(limit, 1000)))}
@router.post("/admin/audit-log/delete")
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
log_id = int(body.id or 0)
if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效")
@@ -71,6 +71,6 @@ def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
@router.post("/admin/audit-log/clear")
def admin_audit_log_clear(auth=Depends(admin_auth)):
def admin_audit_log_clear(auth=Depends(require_permission("log:manage"))):
deleted = log_repository.clear_audit_logs()
return {"msg": "审计日志已清空", "deleted": deleted}
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import admin_auth
from app.core.security import require_permission
from app.repositories import policy_repository
from app.schemas.policy import PolicySaveRequest
from app.services.common_service import policy_row_to_dict, validate_channel_code
@@ -13,7 +13,7 @@ router = APIRouter(tags=["admin-policy"])
@router.get("/admin/policy")
def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
def admin_get_policy(app_id: str, channel: str, auth=Depends(require_permission("policy:view"))):
row = policy_repository.get_policy(app_id, channel)
result = policy_row_to_dict(row)
result.update({"app_id": app_id, "channel": channel, "saved": bool(row)})
@@ -21,7 +21,7 @@ def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
@router.post("/admin/policy/save")
def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
def admin_save_policy(body: PolicySaveRequest, auth=Depends(require_permission("policy:manage"))):
app_id = body.app_id.strip()
channel = body.channel.strip()
if not app_id or not validate_channel_code(channel):
+2 -2
View File
@@ -2,7 +2,7 @@ import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.security import admin_auth
from app.core.security import require_permission
from app.services import publish_service
@@ -11,7 +11,7 @@ PUBLISH_LOCK = asyncio.Lock()
@router.post("/admin/publish")
async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
async def admin_publish_version(request: Request, auth=Depends(require_permission("publish:manage"))):
if PUBLISH_LOCK.locked():
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
async with PUBLISH_LOCK:
+6 -6
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
import minio_tool
from app.core.security import admin_auth
from app.core.security import require_permission
from app.repositories import version_repository
from app.schemas.version import VersionIdRequest, VersionProtocolRequest
from app.services.common_service import is_executable_path
@@ -22,7 +22,7 @@ router = APIRouter(tags=["admin-version"])
@router.post("/admin/version/offline-package")
def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
def admin_offline_package(body: VersionIdRequest, auth=Depends(require_permission("version:view"))):
version_id = int(body.version_id or 0)
ver, rows = version_repository.get_version_with_files(version_id)
if not ver or not rows:
@@ -87,12 +87,12 @@ def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
@router.get("/admin/version/list")
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
def admin_get_version_list(app_id: str, auth=Depends(require_permission("version:view"))):
return {"list": version_repository.list_versions(app_id)}
@router.post("/admin/version/set-protocol")
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_auth)):
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(require_permission("version:manage"))):
version_id = int(body.version_id or 0)
client_protocol = int(body.client_protocol or 0)
if version_id < 1 or client_protocol < 1:
@@ -104,7 +104,7 @@ def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_
@router.post("/admin/version/set-latest")
def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
def admin_set_latest(body: VersionIdRequest, auth=Depends(require_permission("version:manage"))):
vid = body.version_id
result = version_repository.set_latest_version(vid)
if result == "not_found":
@@ -113,7 +113,7 @@ def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
@router.post("/admin/version/delete")
def admin_delete_version(body: VersionIdRequest, auth=Depends(admin_auth)):
def admin_delete_version(body: VersionIdRequest, auth=Depends(require_permission("version:manage"))):
vid = body.version_id
v_info = version_repository.get_version_for_delete(vid)
if not v_info:
+8 -4
View File
@@ -19,14 +19,18 @@ async def admin_audit_middleware(request: Request, call_next):
finally:
if should_audit:
try:
token = request.headers.get("X-Admin-Token", "")
principal = getattr(request.state, "admin_principal", None)
token = request.headers.get("authorization", "") or request.headers.get("X-Admin-Token", "")
conn = db.get_conn()
conn.execute(
"""INSERT INTO admin_audit_logs
(actor_hash,action,method,path,target,result,status_code,ip,user_agent)
VALUES(?,?,?,?,?,?,?,?,?)""",
(actor_hash,actor_username,actor_roles,auth_type,action,method,path,target,result,status_code,ip,user_agent)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(
token_digest(token)[:16] if token else "anonymous",
principal.actor_hash if principal else (token_digest(token)[:16] if token else "anonymous"),
principal.username if principal else "anonymous",
",".join(principal.roles) if principal else "",
principal.auth_type if principal else "",
request.url.path.removeprefix("/admin/"),
request.method,
request.url.path,
+286 -5
View File
@@ -1,20 +1,245 @@
from pathlib import Path
import hashlib
import json
import os
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Callable
from fastapi import Header, HTTPException
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
from fastapi import Depends, Header, HTTPException, Request
from app.core.config import ENV_FILE_PATH, settings
from app.repositories import admin_user_repository
if not settings.admin_token:
raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "").strip() or settings.admin_token
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ADMIN_ACCESS_TOKEN_EXPIRE_MIN", "120"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("ADMIN_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
PASSWORD_HASHER = PasswordHasher()
ROLE_PERMISSIONS = {
"super_admin": ["*:*:*"],
"release_admin": [
"admin:access",
"app:manage",
"channel:manage",
"version:manage",
"publish:manage",
"policy:manage",
"device:view",
"log:view",
"config:view",
],
"license_admin": [
"admin:access",
"license:manage",
"device:view",
"config:view",
"log:view",
],
"auditor": [
"admin:access",
"app:view",
"version:view",
"device:view",
"license:view",
"log:view",
"crash:view",
"config:view",
],
"crash_admin": [
"admin:access",
"crash:view",
"log:view",
],
}
ROLE_NAMES = {
"super_admin": "超级管理员",
"release_admin": "发布管理员",
"license_admin": "授权管理员",
"auditor": "审计人员",
"crash_admin": "崩溃报告管理员",
}
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def utc_now() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def utc_text(value: datetime) -> str:
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_utc_text(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
@dataclass
class AdminPrincipal:
username: str
display_name: str
roles: list[str]
permissions: list[str]
auth_type: str
def has_permission(self, permission: str) -> bool:
return permission_allowed(self.permissions, permission)
@property
def actor_hash(self) -> str:
return token_digest(self.username)[:16]
def role_permissions(roles: list[str]) -> list[str]:
permissions: list[str] = []
for role in roles:
permissions.extend(ROLE_PERMISSIONS.get(role, []))
seen = set()
result = []
for item in permissions:
if item not in seen:
seen.add(item)
result.append(item)
return result
def permission_allowed(permissions: list[str], permission: str) -> bool:
if "*:*:*" in permissions or permission in permissions:
return True
parts = permission.split(":")
if len(parts) >= 2 and f"{parts[0]}:*" in permissions:
return True
if len(parts) == 2 and parts[1] == "view" and f"{parts[0]}:manage" in permissions:
return True
return False
def hash_password(password: str) -> str:
return PASSWORD_HASHER.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return PASSWORD_HASHER.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, ValueError):
return False
def normalize_roles(raw_roles: str | list[str] | None) -> list[str]:
if isinstance(raw_roles, list):
roles = [str(item).strip() for item in raw_roles]
else:
try:
parsed = json.loads(raw_roles or "[]")
except json.JSONDecodeError:
parsed = []
roles = [str(item).strip() for item in parsed if str(item).strip()]
return roles or ["super_admin"]
def principal_from_user_row(row, auth_type: str = "jwt") -> AdminPrincipal:
roles = normalize_roles(row["roles"])
return AdminPrincipal(
username=row["username"],
display_name=row["display_name"] or row["username"],
roles=roles,
permissions=role_permissions(roles),
auth_type=auth_type,
)
def legacy_admin_principal() -> AdminPrincipal:
return AdminPrincipal(
username="legacy-admin-token",
display_name="兼容管理员令牌",
roles=["super_admin"],
permissions=role_permissions(["super_admin"]),
auth_type="legacy_token",
)
def create_jwt_token(username: str, token_type: str, expires_delta: timedelta, roles: list[str] | None = None) -> tuple[str, str, datetime]:
now = utc_now()
expires_at = now + expires_delta
token_id = secrets.token_urlsafe(24)
payload = {
"sub": username,
"typ": token_type,
"jti": token_id,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
}
if roles is not None:
payload["roles"] = roles
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return token, token_id, expires_at
def decode_jwt_token(token: str, expected_type: str) -> dict:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="无效登录凭证")
if payload.get("typ") != expected_type:
raise HTTPException(status_code=401, detail="登录凭证类型错误")
return payload
def token_response_for_user(row, request: Request | None = None) -> dict:
principal = principal_from_user_row(row)
access_token, _, access_expires = create_jwt_token(
principal.username,
"access",
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
principal.roles,
)
refresh_token, refresh_id, refresh_expires = create_jwt_token(
principal.username,
"refresh",
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
)
admin_user_repository.save_refresh_token(
refresh_id,
principal.username,
token_digest(refresh_token),
utc_text(refresh_expires),
request.headers.get("user-agent", "")[:300] if request else "",
request.client.host if request and request.client else "",
)
return {
"success": True,
"data": {
"avatar": "",
"username": principal.username,
"nickname": principal.display_name,
"roles": principal.roles,
"permissions": principal.permissions,
"accessToken": access_token,
"refreshToken": refresh_token,
"expires": utc_text(access_expires),
},
}
def quote_env_value(value: str) -> str:
unsafe_chars = set(" \t\n\r#\"'")
if value and not any(ch in unsafe_chars for ch in value):
@@ -46,10 +271,66 @@ def persist_env_value(path: Path, key: str, value: str) -> bool:
return True
def admin_auth(X_Admin_Token: str = Header("")):
if not secrets.compare_digest(X_Admin_Token, settings.admin_token):
def legacy_admin_auth(X_Admin_Token: str = Header("")):
# Backward-compatible token-only auth for old scripts.
token = X_Admin_Token if isinstance(X_Admin_Token, str) else ""
if token and secrets.compare_digest(token, settings.admin_token):
return legacy_admin_principal()
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
return True
def current_admin(
request: Request,
Authorization: str = Header(""),
X_Admin_Token: str = Header(""),
) -> AdminPrincipal:
authorization = Authorization if isinstance(Authorization, str) else ""
admin_token = X_Admin_Token if isinstance(X_Admin_Token, str) else ""
if authorization.lower().startswith("bearer "):
payload = decode_jwt_token(authorization[7:].strip(), "access")
username = str(payload.get("sub") or "")
row = admin_user_repository.get_user(username)
if not row or row["status"] != "active":
raise HTTPException(status_code=401, detail="管理员账号不存在或已禁用")
principal = principal_from_user_row(row)
request.state.admin_principal = principal
return principal
principal = legacy_admin_auth(admin_token)
request.state.admin_principal = principal
return principal
admin_auth = current_admin
def require_permission(permission: str) -> Callable:
def permission_dependency(principal: AdminPrincipal = Depends(current_admin)):
if not principal.has_permission(permission):
raise HTTPException(status_code=403, detail=f"缺少权限:{permission}")
return principal
return permission_dependency
def ensure_default_admin_user():
username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin"
password = os.getenv("ADMIN_PASSWORD", "").strip() or settings.admin_token
display_name = os.getenv("ADMIN_DISPLAY_NAME", "超级管理员").strip() or username
if not admin_user_repository.has_any_user():
admin_user_repository.create_user(
username,
hash_password(password),
display_name,
json.dumps(["super_admin"], ensure_ascii=False),
"active",
)
print(f"已初始化默认管理员账号:{username}")
return
row = admin_user_repository.get_user(username)
if not row:
return
if row["status"] != "active":
admin_user_repository.set_user_status(username, "active")
ADMIN_TOKEN = settings.admin_token
+135
View File
@@ -0,0 +1,135 @@
import db
def list_users():
conn = db.get_conn()
rows = conn.execute(
"""SELECT id,username,display_name,roles,status,created_at,updated_at,last_login_at
FROM admin_users
ORDER BY id ASC"""
).fetchall()
conn.close()
return rows
def has_any_user() -> bool:
conn = db.get_conn()
row = conn.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
conn.close()
return bool(row)
def get_user(username: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM admin_users WHERE username=?", (username,)).fetchone()
conn.close()
return row
def create_user(username: str, password_hash: str, display_name: str, roles: str, status: str = "active") -> None:
conn = db.get_conn()
conn.execute(
"""INSERT INTO admin_users(username,password_hash,display_name,roles,status)
VALUES(?,?,?,?,?)""",
(username, password_hash, display_name, roles, status),
)
conn.commit()
conn.close()
def update_user_profile(username: str, display_name: str, roles: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_users SET display_name=?,roles=?,updated_at=datetime('now') WHERE username=?",
(display_name, roles, username),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def update_password(username: str, password_hash: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_users SET password_hash=?,updated_at=datetime('now') WHERE username=?",
(password_hash, username),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def count_active_super_admins() -> int:
conn = db.get_conn()
rows = conn.execute(
"SELECT roles FROM admin_users WHERE status='active'"
).fetchall()
conn.close()
count = 0
for row in rows:
if "super_admin" in (row["roles"] or ""):
count += 1
return count
def set_user_status(username: str, status: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_users SET status=?,updated_at=datetime('now') WHERE username=?",
(status, username),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def revoke_refresh_tokens_for_user(username: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_refresh_tokens SET revoked_at=datetime('now') WHERE username=? AND revoked_at IS NULL",
(username,),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def mark_login(username: str) -> None:
conn = db.get_conn()
conn.execute("UPDATE admin_users SET last_login_at=datetime('now') WHERE username=?", (username,))
conn.commit()
conn.close()
def save_refresh_token(token_id: str, username: str, token_hash: str, expires_at: str, user_agent: str, ip: str) -> None:
conn = db.get_conn()
conn.execute(
"""INSERT INTO admin_refresh_tokens(token_id,username,token_hash,expires_at,user_agent,ip)
VALUES(?,?,?,?,?,?)""",
(token_id, username, token_hash, expires_at, user_agent, ip),
)
conn.commit()
conn.close()
def get_refresh_token(token_id: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM admin_refresh_tokens WHERE token_id=?", (token_id,)).fetchone()
conn.close()
return row
def revoke_refresh_token(token_id: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_refresh_tokens SET revoked_at=datetime('now') WHERE token_id=? AND revoked_at IS NULL",
(token_id,),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel
class AdminLoginRequest(BaseModel):
username: str
password: str
class RefreshTokenRequest(BaseModel):
refreshToken: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class AdminUserCreateRequest(BaseModel):
username: str
password: str
display_name: str = ""
roles: list[str] = ["auditor"]
class AdminUserUpdateRequest(BaseModel):
username: str
display_name: str = ""
roles: list[str]
class AdminUserStatusRequest(BaseModel):
username: str
status: str
class AdminUserResetPasswordRequest(BaseModel):
username: str
new_password: str
+1
View File
@@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel):
client_protocol: int = 3
license_key: str = ""
api_base_url: str = ""
install_root: str = ""
main_executable: str = ""
-1
View File
@@ -57,7 +57,6 @@ def default_client_main_executable() -> str:
def default_client_config_values(request: Request) -> dict:
return {
"api_base_url": public_api_base_url(request),
"client_token": VALID_CLIENT_TOKEN or "",
"launch_token": os.getenv("CLIENT_LAUNCH_TOKEN", "SimCAE_Launch_Token_2026_ChangeMe_32Bytes"),
"request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
+51 -11
View File
@@ -37,6 +37,41 @@ PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
def normalize_install_root(raw_value: str) -> str:
value = str(raw_value or os.getenv("CLIENT_INSTALL_ROOT", "..")).strip().replace(chr(92), "/")
if value in ("", "."):
return "."
if value == "..":
return ".."
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
def normalize_publish_executable(raw_value: str) -> str:
value = str(raw_value or "").strip().replace(chr(92), "/").strip("/")
if not value:
value = RELEASE_MAIN_EXECUTABLE.rsplit("/", 1)[-1]
if TARGET_PLATFORM == "linux" and value.casefold().endswith(".exe"):
value = value[:-4]
elif TARGET_PLATFORM == "windows":
parts = value.split("/")
leaf = parts[-1]
if "." not in leaf:
leaf += ".exe"
parts[-1] = leaf
value = "/".join(parts)
return normalize_relative_path(value)
def release_main_executable_for_install(install_root: str, main_executable: str) -> str:
normalized_install_root = normalize_install_root(install_root)
normalized_main = normalize_publish_executable(main_executable)
if normalized_install_root == ".":
return normalized_main
if normalized_main.casefold().startswith("bin/"):
return normalized_main
return normalize_relative_path(f"bin/{normalized_main}")
def normalize_relative_path(raw_path: str) -> str:
if not isinstance(raw_path, str):
raise HTTPException(status_code=400, detail="文件相对路径无效")
@@ -256,7 +291,7 @@ def extract_release_archive(archive_path: Path, filename: str, extract_dir: Path
raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar")
def release_items_from_extracted_dir(extract_dir: Path):
def release_items_from_extracted_dir(extract_dir: Path, required_main_executable: str):
items = []
for path in extract_dir.rglob("*"):
if path.is_symlink() or not path.is_file():
@@ -265,13 +300,13 @@ def release_items_from_extracted_dir(extract_dir: Path):
if should_skip_release_path(rel_path):
continue
items.append({"path": rel_path, "local_path": path})
return normalize_release_item_roots(items)
return normalize_release_item_roots(items, required_main_executable)
def normalize_release_item_roots(items: list[dict]):
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
if not items:
return items
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
required_main_key = required_main_executable.casefold()
lower_paths = [item["path"].casefold() for item in items]
if required_main_key in lower_paths:
return items
@@ -298,7 +333,7 @@ def normalize_release_item_roots(items: list[dict]):
return normalized
def validate_release_items(items: list[dict]):
def validate_release_items(items: list[dict], required_main_executable: str):
if not items:
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
if len(items) > PUBLISH_MAX_FILES:
@@ -322,15 +357,15 @@ def validate_release_items(items: list[dict]):
if not filtered:
raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件")
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
required_main_key = required_main_executable.casefold()
if required_main_key not in seen_paths:
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
if nested_main:
raise HTTPException(
status_code=400,
detail=f"{RELEASE_MAIN_EXECUTABLE} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
detail=f"{required_main_executable} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
)
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {RELEASE_MAIN_EXECUTABLE}")
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {required_main_executable}")
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
if nested_main:
raise HTTPException(
@@ -461,6 +496,9 @@ async def collect_publish_items(request: Request):
app_id = form.get("app_id")
channel = form.get("channel") or ""
version = form.get("version")
install_root = normalize_install_root(str(form.get("install_root") or ""))
main_executable = str(form.get("main_executable") or "")
required_main_executable = release_main_executable_for_install(install_root, main_executable)
try:
client_protocol = int(form.get("client_protocol") or 2)
except (TypeError, ValueError):
@@ -526,8 +564,8 @@ async def collect_publish_items(request: Request):
extract_dir = archive_temp_dir / "extracted"
extract_dir.mkdir(parents=True, exist_ok=True)
extract_release_archive(archive_path, archive_name, extract_dir)
raw_items = release_items_from_extracted_dir(extract_dir)
upload_items = validate_release_items(raw_items)
raw_items = release_items_from_extracted_dir(extract_dir, required_main_executable)
upload_items = validate_release_items(raw_items, required_main_executable)
extracted_total = sum(item["local_path"].stat().st_size for item in upload_items)
check_extracted_publish_limits(extracted_total, len(upload_items))
else:
@@ -540,7 +578,7 @@ async def collect_publish_items(request: Request):
for index, file in enumerate(files):
raw_path = relative_paths[index] if relative_paths else file.filename
raw_items.append({"upload": file, "path": str(raw_path)})
upload_items = validate_release_items(raw_items)
upload_items = validate_release_items(raw_items, required_main_executable)
except Exception:
if archive_upload is not None:
await close_upload_safely(archive_upload)
@@ -552,6 +590,8 @@ async def collect_publish_items(request: Request):
"channel": str(channel),
"version": str(version),
"client_protocol": client_protocol,
"install_root": install_root,
"required_main_executable": required_main_executable,
"upload_items": upload_items,
"archive_upload": archive_upload,
"archive_temp_dir": archive_temp_dir,
+11
View File
@@ -18,6 +18,7 @@ import minio_tool
from app.api.router import include_api_routes
from app.core.audit import admin_audit_middleware
from app.core.config import configured_path, env_bool, settings
from app.core.security import ensure_default_admin_user
from app.services.crash_api_service import ensure_storage_dirs
from app.services.device_credential_service import verify_device_credential
from app.services.publish_service import UPLOAD_SPOOL_DIR
@@ -54,6 +55,13 @@ 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 ''")
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 ''")
if "actor_roles" not in audit_columns:
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN actor_roles TEXT NOT NULL DEFAULT ''")
if "auth_type" not in audit_columns:
cur.execute("ALTER TABLE admin_audit_logs ADD COLUMN auth_type TEXT NOT NULL DEFAULT ''")
for app_row in cur.execute("SELECT app_id FROM apps").fetchall():
if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone():
cur.executemany(
@@ -72,6 +80,7 @@ async def lifespan(app: FastAPI):
print("===== 数据库初始化完成 =====")
ensure_storage_dirs()
ensure_default_admin_user()
start_minio_if_needed()
yield
@@ -175,6 +184,8 @@ async def auth_middleware(request: Request, call_next):
"/platform-config.json",
"/logo.svg",
"/health",
"/login",
"/refresh-token",
"/api/v1/health",
"/api/v1/crash-reports",
"/api/v1/symbols",
+2
View File
@@ -1,6 +1,8 @@
cryptography==49.0.0
fastapi==0.138.0
minio==7.2.20
argon2-cffi==25.1.0
PyJWT==2.10.1
python-dotenv==1.2.2
python-multipart==0.0.32
uvicorn==0.49.0
+10 -8
View File
@@ -312,10 +312,12 @@ MinIO 控制台: http://你的服务器IP:9001/
| --- | --- | --- | --- |
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成 qrc 服务端配置 `server_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`Linux 示例:`bin/SimCAE`。 |
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 必填 | 可以 | 管理后台初始用户名和密码。首次启动且数据库里没有管理员用户时,会自动创建这个账号。网页登录时输入这组账号密码。 |
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台兼容令牌。新后台默认不用它登录;它仍用于旧脚本兼容、JWT_SECRET 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
@@ -373,15 +375,15 @@ MinIO 控制台: http://你的服务器IP:9001/
3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
4. 打开“客户端配置生成”
5. 点击“生成配套配置”
6. 点击“复制配置”
7. 粘贴到客户端 bin/config/app_config.json
6. 点击“复制配置”,粘贴到客户端 `bin/config/app_config.json`
7. 点击“复制 qrc 配置”,粘贴到客户端源码 `config/server_config.json`
8. 重新编译 Launcher / Updater / Bootstrap,让 `api_base_url` 通过 qrc 编进程序
```
客户端 `config/app_config.json` 至少要和服务端保持这个值一致:
客户端 `config/app_config.json` 至少要和服务端保持这个值一致:
```json
{
"api_base_url": "http://你的服务器IP:8000",
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
}
```
@@ -398,7 +400,7 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
"client_token": "SimCAEClientToken2026"
```
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。它现在位于客户端源码 `config/server_config.json`,并通过 qrc 编译进程序,不再写入客户端 `app_config.json` 或注册表。
## 六、发布新版本
@@ -548,7 +550,7 @@ MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
默认用户名、默认密码、默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
EOF_README
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
+29 -1
View File
@@ -111,13 +111,41 @@ CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,c
-- 11. 管理后台写操作审计
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, actor_username TEXT NOT NULL DEFAULT '',
actor_roles TEXT NOT NULL DEFAULT '', auth_type TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL,
status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at);
-- 11.1 管理后台用户、角色与刷新令牌
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
roles TEXT NOT NULL DEFAULT '["super_admin"]',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
last_login_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_admin_users_status ON admin_users(status);
CREATE TABLE IF NOT EXISTS admin_refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
user_agent TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_admin_refresh_tokens_username ON admin_refresh_tokens(username,expires_at);
-- 12. SimCAE 崩溃报告原始数据索引
CREATE TABLE IF NOT EXISTS crash_reports (