feat: 优化发布清单和安装结构自动识别
This commit is contained in:
+14
-2
@@ -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 原文。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">安全兜底仍会过滤 .git、build、debug、update、update_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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "密码已更新,请重新登录"}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 不能为空")
|
||||
|
||||
@@ -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,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,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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel):
|
||||
client_protocol: int = 3
|
||||
license_key: str = ""
|
||||
api_base_url: str = ""
|
||||
install_root: str = ""
|
||||
main_executable: str = ""
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user