From 4df439b5cebdc4aa97f60d06a9a7683f16a66b03 Mon Sep 17 00:00:00 2001 From: nikelaluo <2135665716@qq.com> Date: Wed, 15 Jul 2026 06:33:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E5=92=8C=E5=AE=89=E8=A3=85=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.docker.example | 16 +- Docs/01-服务端Docker打包部署指南.md | 18 +- Docs/03-后端工程化结构说明.md | 26 +- admin-ui/src/api/simcae.ts | 19 +- admin-ui/src/views/login/index.vue | 67 +- admin-ui/src/views/simcae/index.vue | 791 ++++++++++++++++++++-- app/api/router.py | 2 + app/api/routes/admin_app_channel.py | 10 +- app/api/routes/admin_auth.py | 248 +++++++ app/api/routes/admin_config.py | 33 +- app/api/routes/admin_crash_report.py | 9 +- app/api/routes/admin_device.py | 6 +- app/api/routes/admin_license.py | 10 +- app/api/routes/admin_logs.py | 20 +- app/api/routes/admin_policy.py | 6 +- app/api/routes/admin_publish.py | 4 +- app/api/routes/admin_version.py | 12 +- app/core/audit.py | 12 +- app/core/security.py | 293 +++++++- app/repositories/admin_user_repository.py | 135 ++++ app/schemas/admin_auth.py | 38 ++ app/schemas/admin_config.py | 1 + app/services/admin_config_service.py | 1 - app/services/publish_service.py | 62 +- main.py | 11 + requirements.txt | 2 + scripts/package-offline-server.sh | 18 +- tables.sql | 30 +- 28 files changed, 1702 insertions(+), 198 deletions(-) create mode 100644 app/api/routes/admin_auth.py create mode 100644 app/repositories/admin_user_repository.py create mode 100644 app/schemas/admin_auth.py diff --git a/.env.docker.example b/.env.docker.example index 3aebe75..68aaca5 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -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 原文。 diff --git a/Docs/01-服务端Docker打包部署指南.md b/Docs/01-服务端Docker打包部署指南.md index 8bed406..5b83695 100644 --- a/Docs/01-服务端Docker打包部署指南.md +++ b/Docs/01-服务端Docker打包部署指南.md @@ -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 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 diff --git a/Docs/03-后端工程化结构说明.md b/Docs/03-后端工程化结构说明.md index efd4bea..b9b4dca 100644 --- a/Docs/03-后端工程化结构说明.md +++ b/Docs/03-后端工程化结构说明.md @@ -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 diff --git a/admin-ui/src/api/simcae.ts b/admin-ui/src/api/simcae.ts index 85ef9b0..4b60078 100644 --- a/admin-ui/src/api/simcae.ts +++ b/admin-ui/src/api/simcae.ts @@ -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( } = {} ): Promise { 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) { diff --git a/admin-ui/src/views/login/index.vue b/admin-ui/src/views/login/index.vue index be43f18..d6add09 100644 --- a/admin-ui/src/views/login/index.vue +++ b/admin-ui/src/views/login/index.vue @@ -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 }) => { + + + + + + diff --git a/admin-ui/src/views/simcae/index.vue b/admin-ui/src/views/simcae/index.vue index 68ce254..fb7d10f 100644 --- a/admin-ui/src/views/simcae/index.vue +++ b/admin-ui/src/views/simcae/index.vue @@ -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([]); const upgradeLogs = ref([]); const downloadLogs = ref([]); const auditLogs = ref([]); +const adminUsers = ref([]); +const adminRoleOptions = ref([]); +const currentAdminUsername = ref(""); const currentAppId = ref(""); const createdLicenseKey = ref(""); const generatedClientConfig = ref(""); +const generatedServerConfig = ref(""); const generatedCrashTestInfo = ref(""); const licenseConfigWarning = ref(""); const runtime = reactive({}); @@ -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([]); const archiveFile = ref(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,43 +148,28 @@ const currentPolicyDisabledText = computed(() => { return disabled.length ? disabled.join(", ") : "无"; }); -const uploadEntries = computed(() => { - const protectedFiles = new Set([ - "client.ini", - "bootstrap.exe", - "config/app_config.json", - "config/local_state.json", - "config/client_identity.dat", - "config/version_policy.dat" - ]); +const releaseManifestRules = computed(() => + parseReleaseManifest(releaseManifestText.value, selectedReleaseRoot.value) +); + +const uploadEntries = computed(() => { + 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 => { - const path = item.path.toLowerCase(); - const parts = path.split("/"); - const blockedDirectory = parts - .slice(0, -1) - .some( - part => - part === ".git" || - part === ".vs" || - part === "debug" || - part === "update" || - part === "update_temp" || - part === "cmakefiles" || - part.startsWith("build") || - part.endsWith("_autogen") - ); - const runtimeFile = - protectedFiles.has(path) || - (path.startsWith("bin/") && protectedFiles.has(path.slice(4))); - return ( - !blockedDirectory && - !runtimeFile && - !path.endsWith(".pdb") && - !path.endsWith(".ilk") && - !path.endsWith(".obj") - ); + let selected = !hasIncludeRules; + for (const rule of rules) { + if (manifestRuleMatches(rule.pattern, item.path)) { + selected = !rule.exclude; + } + } + return selected && isSafePublishPath(item.path); }); }); @@ -157,25 +182,271 @@ const publishSummary = computed(() => { 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 "已选择文件夹,但有效发布文件为空,请检查是否选到了 build/update/debug 等运行目录"; + return "发布清单没有匹配到有效文件,请检查清单路径或是否被安全规则过滤"; } const preview = uploadEntries.value .slice(0, 6) .map(item => item.path) .join("、"); - return `已选择 ${uploadEntries.value.length} 个有效文件:${preview}${ + return `已读取 ${releaseManifestName.value},将发布 ${uploadEntries.value.length} 个文件:${preview}${ uploadEntries.value.length > 6 ? " ..." : "" }`; }); -function relativePathForFile(file: File) { +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", + "config/app_config.json", + "config/local_state.json", + "config/client_identity.dat", + "config/version_policy.dat" + ]); + const path = normalizePublishPath(itemPath).toLowerCase(); + const parts = path.split("/"); + const blockedDirectory = parts + .slice(0, -1) + .some( + part => + part === ".git" || + part === ".vs" || + part === "debug" || + part === "update" || + part === "update_temp" || + part === "cmakefiles" || + part.startsWith("build") || + part.endsWith("_autogen") + ); + const runtimeFile = + protectedFiles.has(path) || + (path.startsWith("bin/") && protectedFiles.has(path.slice(4))); + return ( + !blockedDirectory && + !runtimeFile && + !path.endsWith(".pdb") && + !path.endsWith(".ilk") && + !path.endsWith(".obj") + ); +} + +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`; + } + 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) { + 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("/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("/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( @@ -666,22 +999,129 @@ async function clearLog(endpoint: string, reload: () => Promise) { } } -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("/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 发布根目录,或上传已经压缩好的发布包。目录模式会按发布清单决定上传范围。" /> @@ -827,10 +1268,36 @@ onMounted(loadAll);
不确定时保持默认值;只有客户端升级协议变化时才需要调整。
+ Release 目录 压缩包 +
+
目录模式需要在所选目录根部放置 发布清单.txt,也兼容 release_manifest.txt
+
+ 例如你选择的是 test_file 文件夹,就在 test_file/发布清单.txt 里写下面这些规则: +
+
# 发布 test_file 文件夹下的所有内容
+test_file/*
+
+# 不发布升级运行时目录
+!test_file/update/*
+
+# 不发布调试符号文件
+!test_file/*.pdb
+
+ 普通路径表示要发布;前缀 ! 表示排除;空行和 # 注释会忽略;后面的规则优先。也可以省略根目录名,例如写 *、!update/*、!*.pdb。 +
+
安全兜底仍会过滤 .git、build、debug、update、update_temp、调试文件和客户端本地配置文件。
+
@@ -944,14 +1411,20 @@ onMounted(loadAll); - + + +
这个地址会生成到 qrc 编译配置,不会写入客户端 app_config.json。
+
-
生成配套配置复制配置
+
生成配套配置复制 app_config
客户端 app_config.json
{{ generatedClientConfig || "尚未生成客户端配置" }}
+ +
qrc 服务端配置 server_config.json复制 qrc 配置
+
{{ generatedServerConfig || "生成配套配置后显示 qrc 服务端配置" }}
崩溃报告接口联调信息复制联调信息
{{ generatedCrashTestInfo || "生成配套配置后显示崩溃报告接口、Token 和大小限制" }}
@@ -1012,7 +1485,10 @@ onMounted(loadAll); 清空 - + + + + @@ -1026,14 +1502,155 @@ onMounted(loadAll); + - - + + + - 更改管理员令牌 + 修改密码 + + + + + + + + + + + + + + + + + + + + + + 创建管理员 + + + + + + + + + + + + + + + + + + + + {{ launcherDisplayName() }} 在发布根目录/bin/ + {{ launcherDisplayName() }} 在发布根目录/ + +
+ 当前选择:{{ installRootDescription() }};发布包必须包含 {{ expectedReleaseMainExecutable }}。确认后会写入生成的 app_config.json。 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -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 { diff --git a/app/api/router.py b/app/api/router.py index e0b3fa0..c8fbb67 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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) diff --git a/app/api/routes/admin_app_channel.py b/app/api/routes/admin_app_channel.py index 4661220..14805ca 100644 --- a/app/api/routes/admin_app_channel.py +++ b/app/api/routes/admin_app_channel.py @@ -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() diff --git a/app/api/routes/admin_auth.py b/app/api/routes/admin_auth.py new file mode 100644 index 0000000..dbd9c01 --- /dev/null +++ b/app/api/routes/admin_auth.py @@ -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": "密码已更新,请重新登录"} diff --git a/app/api/routes/admin_config.py b/app/api/routes/admin_config.py index b742162..9fa42b3 100644 --- a/app/api/routes/admin_config.py +++ b/app/api/routes/admin_config.py @@ -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), } diff --git a/app/api/routes/admin_crash_report.py b/app/api/routes/admin_crash_report.py index 3acc795..5e06abc 100644 --- a/app/api/routes/admin_crash_report.py +++ b/app/api/routes/admin_crash_report.py @@ -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], ) diff --git a/app/api/routes/admin_device.py b/app/api/routes/admin_device.py index caa78bb..af505c6 100644 --- a/app/api/routes/admin_device.py +++ b/app/api/routes/admin_device.py @@ -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() diff --git a/app/api/routes/admin_license.py b/app/api/routes/admin_license.py index 6e1bf32..e04afe7 100644 --- a/app/api/routes/admin_license.py +++ b/app/api/routes/admin_license.py @@ -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 不能为空") diff --git a/app/api/routes/admin_logs.py b/app/api/routes/admin_logs.py index 6286e82..258f879 100644 --- a/app/api/routes/admin_logs.py +++ b/app/api/routes/admin_logs.py @@ -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} diff --git a/app/api/routes/admin_policy.py b/app/api/routes/admin_policy.py index 38430b1..c0e1dc1 100644 --- a/app/api/routes/admin_policy.py +++ b/app/api/routes/admin_policy.py @@ -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): diff --git a/app/api/routes/admin_publish.py b/app/api/routes/admin_publish.py index 10877a0..3550d48 100644 --- a/app/api/routes/admin_publish.py +++ b/app/api/routes/admin_publish.py @@ -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: diff --git a/app/api/routes/admin_version.py b/app/api/routes/admin_version.py index f6ef639..c4d0c20 100644 --- a/app/api/routes/admin_version.py +++ b/app/api/routes/admin_version.py @@ -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: diff --git a/app/core/audit.py b/app/core/audit.py index 9ac6a1d..11c5d3b 100644 --- a/app/core/audit.py +++ b/app/core/audit.py @@ -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, diff --git a/app/core/security.py b/app/core/security.py index b1f069f..628f343 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -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): - raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问") - return True +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="后台密钥错误,禁止访问") + + +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 diff --git a/app/repositories/admin_user_repository.py b/app/repositories/admin_user_repository.py new file mode 100644 index 0000000..522d9bd --- /dev/null +++ b/app/repositories/admin_user_repository.py @@ -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 diff --git a/app/schemas/admin_auth.py b/app/schemas/admin_auth.py new file mode 100644 index 0000000..c69937d --- /dev/null +++ b/app/schemas/admin_auth.py @@ -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 diff --git a/app/schemas/admin_config.py b/app/schemas/admin_config.py index 9b27455..de876e2 100644 --- a/app/schemas/admin_config.py +++ b/app/schemas/admin_config.py @@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel): client_protocol: int = 3 license_key: str = "" api_base_url: str = "" + install_root: str = "" main_executable: str = "" diff --git a/app/services/admin_config_service.py b/app/services/admin_config_service.py index 1c8e48b..ac0d3fa 100644 --- a/app/services/admin_config_service.py +++ b/app/services/admin_config_service.py @@ -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"), diff --git a/app/services/publish_service.py b/app/services/publish_service.py index 093a98d..6fdce90 100644 --- a/app/services/publish_service.py +++ b/app/services/publish_service.py @@ -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, diff --git a/main.py b/main.py index c84fc3a..e91c902 100644 --- a/main.py +++ b/main.py @@ -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", diff --git a/requirements.txt b/requirements.txt index 1025912..d3148ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/package-offline-server.sh b/scripts/package-offline-server.sh index a6f7ef9..7a49955 100755 --- a/scripts/package-offline-server.sh +++ b/scripts/package-offline-server.sh @@ -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' diff --git a/tables.sql b/tables.sql index f4f21e0..aa055e3 100644 --- a/tables.sql +++ b/tables.sql @@ -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 (