Compare commits

...

7 Commits

61 changed files with 3434 additions and 10815 deletions
+23 -3
View File
@@ -5,7 +5,7 @@ SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0
SERVER_PORT=8000 SERVER_PORT=8000
# 如果后台经过域名、反向代理或端口映射访问,可以在这里固定客户端 API 地址。 # 如果后台经过域名、反向代理或端口映射访问,可以在这里固定客户端 API 地址。
# 留空时,管理页会按当前访问后台的地址自动生成 api_base_url。 # 留空时,管理页会按当前访问后台的地址自动生成 qrc 服务端配置里的 api_base_url。
PUBLIC_API_BASE_URL= PUBLIC_API_BASE_URL=
# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。 # 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。
@@ -44,14 +44,34 @@ UPLOAD_SPACE_RESERVE_MB=256
# 管理页“客户端配置生成”会自动把这个值写入 app_config.json 的 client_token。 # 管理页“客户端配置生成”会自动把这个值写入 app_config.json 的 client_token。
CLIENT_API_TOKEN=SimCAEClientToken2026 CLIENT_API_TOKEN=SimCAEClientToken2026
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在管理员令牌输入这个值,正式部署建议修改 # 服务端兜底令牌。管理后台使用用户名/密码登录 + JWT,不再用它直接登录
# 这个值仍用于 ADMIN_JWT_SECRET 未设置时的默认签名密钥,以及 CRASH_ADMIN_TOKEN 为空时的崩溃报告管理兜底令牌。
ADMIN_TOKEN=SimCAEAdminToken2026 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 Key 加密保存密钥。后台授权列表需要用它解密显示 License Key。
# 已给默认值,可直接试跑;正式部署建议修改,并且部署后长期保持不变。 # 已给默认值,可直接试跑;正式部署建议修改,并且部署后长期保持不变。
# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 # 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。
LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe
# Gitea 标签清单。GITEA_TOKEN 只放服务端,绝不返回给 Launcher。
# 策略页勾选“允许 Launcher 生成 Git 标签清单”后,服务端会用这里的配置拉取仓库 tags。
GITEA_BASE_URL=https://git.alimzs.com:6443
GITEA_TOKEN=7fb1c733e49c6feb50c8fff900d71f7c97ebfa3d
GITEA_VERIFY_SSL=false
GITEA_TAG_CACHE_TTL_SEC=300
GITEA_TIMEOUT_SEC=12
# 管理页生成 app_config.json 时使用的客户端默认值。普通 Windows SimCAE 部署通常不用改。 # 管理页生成 app_config.json 时使用的客户端默认值。普通 Windows SimCAE 部署通常不用改。
# Linux 部署可以改成: # Linux 部署可以改成:
# CLIENT_MAIN_EXECUTABLE=SimCAE # CLIENT_MAIN_EXECUTABLE=SimCAE
@@ -95,7 +115,7 @@ MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe
# MinIO 存储桶名称。通常不用改。 # MinIO 存储桶名称。通常不用改。
MINIO_BUCKET=updates MINIO_BUCKET=updates
# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 # 客户端下载升级文件时访问的 MinIO 地址。必须改成客户端能访问到的服务器地址。
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
# MinIO 预签名下载链接有效期,单位分钟。通常不用改。 # MinIO 预签名下载链接有效期,单位分钟。通常不用改。
+6 -2
View File
@@ -1,3 +1,5 @@
# 第一阶段:构建 Vue 管理后台。
# 最终镜像只需要 admin-ui/dist,不需要 Node、源码依赖和 node_modules。
FROM node:22-alpine AS admin_ui_builder FROM node:22-alpine AS admin_ui_builder
WORKDIR /ui WORKDIR /ui
@@ -9,6 +11,8 @@ COPY admin-ui ./
RUN npm run build RUN npm run build
# 第二阶段:运行 FastAPI 服务端。
# 镜像里只放后端代码、Python 依赖和已经构建好的管理后台静态文件。
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -26,12 +30,12 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY main.py db.py minio_tool.py tables.sql ./ COPY main.py db.py minio_tool.py tables.sql ./
COPY app ./app COPY app ./app
COPY --chown=updateapp:updateapp legacy/admin.html ./legacy/admin.html
COPY --from=admin_ui_builder --chown=updateapp:updateapp /ui/dist ./admin-ui/dist COPY --from=admin_ui_builder --chown=updateapp:updateapp /ui/dist ./admin-ui/dist
# 运行数据不进镜像,启动时通过 docker-compose 挂载到 /data、/minio_data、/run/secrets/update-keys。
# 这样升级文件、SQLite 数据库和私钥可以独立备份,也不会跟镜像版本绑死。
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
&& chown -R updateapp:updateapp /app /data \ && chown -R updateapp:updateapp /app /data \
&& chmod 644 /app/legacy/admin.html \
&& find /app/admin-ui/dist -type f -exec chmod 644 {} \; \ && find /app/admin-ui/dist -type f -exec chmod 644 {} \; \
&& find /app/admin-ui/dist -type d -exec chmod 755 {} \; && find /app/admin-ui/dist -type d -exec chmod 755 {} \;
@@ -0,0 +1,58 @@
服务端文档入口
==============
你第一次打开 update-server/Docs 时,先看这一份。这里告诉你每份文档是干什么的,以及部署、测试、维护分别该看哪一份。
文档阅读顺序
============
1. 01-服务端Docker打包部署指南.md
适合部署和运维。说明 Docker 离线包里有什么、怎么打包、怎么部署、.env 每个字段怎么填、怎么查看日志和备份数据。
2. 02-崩溃报告接口联调指南.md
适合测试人员、客户端崩溃上报接入人员。说明 CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN、CRASH_ADMIN_TOKEN 分别怎么用,并给出可直接复制的 curl 示例。
3. 03-后端工程化结构说明.md
适合后端维护人员。说明当前 FastAPI 后端如何按 routes/services/repositories/schemas 分层,以及后续工程化方向。
常用任务入口
============
如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接看:
```text
01-服务端Docker打包部署指南.md 的“三、使用者:最小部署流程”
```
如果你要重新生成 Docker 离线部署包,执行:
```bash
cd update-server
bash ./scripts/package-offline-server.sh --version 0.1.0 --output-dir ./dist/SimCAEServerDockerPackage
```
常用源码入口:
- main.py:服务启动、数据库初始化、MinIO 初始化、全局客户端鉴权。
- app/api/routes/:管理后台、客户端更新、崩溃报告等 HTTP 路由。
- app/services/:业务逻辑。
- app/repositories/SQLite 数据库读写。
- admin-ui/:新版管理后台前端源码。
- scripts/package-offline-server.sh:生成服务端 Docker 离线部署包。
本地自动化测试:
```bash
cd update-server
./venv/bin/pip install -r requirements-dev.txt
./venv/bin/python3 -m pytest -q
```
当前测试会自动创建临时数据库、临时签名密钥和临时存储目录,不会改动本地 `mini.db`、`.env` 或真实上传文件。覆盖范围包括管理员登录/JWT、应用创建、License 创建设备登记、更新检查、发布入库/文件存储回退、崩溃报告上传幂等和管理员查询。
本地源码启动:
```bash
cd update-server
./venv/bin/python3 main.py
```
@@ -1,4 +1,4 @@
# SimCAE 服务端 Docker 部署包说明 # SimCAE 服务端 Docker 打包和部署指南
本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。 本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。
@@ -6,6 +6,17 @@
如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。 如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。
## 先看这里:你要做哪件事
| 你的目标 | 直接看哪一节 |
| --- | --- |
| 从源码重新生成离线部署包 | 一、维护者:生成 Docker 离线部署包 |
| 了解交付包里有哪些文件 | 二、这个包里面有什么 |
| 在服务器上部署并启动 | 三、使用者:最小部署流程 |
| 修改 `.env` 配置 | 四、.env 配置怎么填 |
| 发布 Windows/Linux 客户端版本 | 六、发布新版本 |
| 查看日志、重启、备份、升级 | 七、常用命令 |
## 一、维护者:生成 Docker 离线部署包 ## 一、维护者:生成 Docker 离线部署包
在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。 在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。
@@ -139,7 +150,7 @@ bash ./load-images.sh
nano .env nano .env
``` ```
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: 最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成客户端能访问到的服务器地址:
```text ```text
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
@@ -189,10 +200,12 @@ MinIO 控制台: http://你的服务器IP:9001/
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | | `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | | `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 | | `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成 qrc 服务端配置 `server_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`Linux 示例:`bin/SimCAE`。 | | `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`Linux 示例:`bin/SimCAE`。 |
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | | `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 必填 | 可以 | 管理后台初始用户名和密码。首次启动且数据库里没有管理员用户时,会自动创建这个账号。网页登录时输入这组账号密码。 |
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | | `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | | `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | | `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
@@ -213,6 +226,18 @@ MinIO 控制台: http://你的服务器IP:9001/
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 | | `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 | | `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 |
### Git 标签清单参数
这些字段用于管理后台策略里的“允许 Launcher 生成 Git 标签清单”。开启后,Launcher 会请求服务端生成 `tags.txt`;服务端用这里的 Gitea 配置访问仓库 tags,然后只把整理后的文本返回给 Launcher。
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
| --- | --- | --- | --- |
| `GITEA_BASE_URL` | 可不填 | 可以 | Gitea 服务地址,默认 `https://git.alimzs.com:6443`。如果公司 Git 地址变化,就改这里。 |
| `GITEA_TOKEN` | 查私有仓库时必填 | 不填只能查公开仓库 | Gitea 访问令牌。只保存在服务端 `.env`,不要写进客户端配置,不要提交 Git,不会返回给 Launcher。 |
| `GITEA_VERIFY_SSL` | 可不填 | 可以 | 是否校验 Gitea HTTPS 证书。内网自签证书可保持 `false`;正式可信证书建议改成 `true`。 |
| `GITEA_TAG_CACHE_TTL_SEC` | 可不填 | 可以 | tags 缓存时间,默认 `300` 秒,避免每次打开页面或启动 Launcher 都打满 Git 服务。 |
| `GITEA_TIMEOUT_SEC` | 可不填 | 可以 | 单次请求 Gitea 的超时时间,默认 `12` 秒。 |
### 发布保护参数 ### 发布保护参数
这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。 这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。
@@ -240,7 +265,7 @@ MinIO 控制台: http://你的服务器IP:9001/
| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 | | `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 |
| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 | | `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 |
崩溃报告上传、查询、下载、符号包上传的 curl 联调步骤见 `Docs/崩溃报告接口联调说明.md`。示例里的 `dev-token` 不是固定值,要按接口用途替换为 `CRASH_REPORT_TOKEN``CRASH_SYMBOL_TOKEN``CRASH_ADMIN_TOKEN` / `ADMIN_TOKEN` 崩溃报告上传、查询、下载、符号包上传的 curl 联调步骤见 `Docs/02-崩溃报告接口联调指南.md`。示例里的 `dev-token` 不是固定值,要按接口用途替换为 `CRASH_REPORT_TOKEN``CRASH_SYMBOL_TOKEN``CRASH_ADMIN_TOKEN` / `ADMIN_TOKEN`
## 五、客户端配置要同步哪些值 ## 五、客户端配置要同步哪些值
@@ -252,15 +277,15 @@ MinIO 控制台: http://你的服务器IP:9001/
3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成” 3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
4. 打开“客户端配置生成” 4. 打开“客户端配置生成”
5. 点击“生成配套配置” 5. 点击“生成配套配置”
6. 点击“复制配置” 6. 点击“复制配置”,粘贴到客户端 `bin/config/app_config.json`
7. 粘贴到客户端 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 ```json
{ {
"api_base_url": "http://你的服务器IP:8000",
"client_token": "和服务端 CLIENT_API_TOKEN 一样" "client_token": "和服务端 CLIENT_API_TOKEN 一样"
} }
``` ```
@@ -277,7 +302,7 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
"client_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` 或注册表。
## 六、发布新版本 ## 六、发布新版本
@@ -288,6 +313,8 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar 2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
``` ```
点击“发布版本”后,浏览器会先把文件上传到服务端。上传完成后,服务端会创建后台发布任务,继续执行解压、Manifest 校验、数据库写入和 MinIO 上传;管理页面会轮询并显示任务状态。这样可以减少大版本发布时长时间占用同一个 HTTP 请求的问题。
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env``RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是: 压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env``RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
```text ```text
@@ -421,10 +448,10 @@ CLIENT_API_TOKEN=...
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
``` ```
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。 这个地址必须从客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
## 十、安全提醒 ## 十、安全提醒
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 `keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 默认用户名、默认密码、默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
@@ -1,7 +1,14 @@
# SimCAE 崩溃报告接口联调说明 # SimCAE 崩溃报告接口联调指南
本文说明崩溃报告后端接口怎么测试,以及 curl 示例里的 `dev-token` 应该替换成什么。 本文说明崩溃报告后端接口怎么测试,以及 curl 示例里的 `dev-token` 应该替换成什么。
最快结论:
- 上传崩溃报告时,`dev-token` 替换成 `CRASH_REPORT_TOKEN` 的值。
- 上传符号包时,`dev-token` 替换成 `CRASH_SYMBOL_TOKEN` 的值。
- 查询或下载崩溃报告时,优先用 `CRASH_ADMIN_TOKEN`;如果它为空,就用 `ADMIN_TOKEN`
- 管理后台“客户端配置生成”区域下方会显示这些联调信息,方便复制。
## 一、先搞清楚三个 Token ## 一、先搞清楚三个 Token
curl 示例里的: curl 示例里的:
@@ -1,15 +1,33 @@
# 后端成熟模板化改造说明 # 后端工程化结构说明
本文给后端维护人员阅读。它说明服务端为什么从单个 `main.py` 拆成 `routes / services / repositories / schemas`,以及后续继续工程化时应该把代码放在哪里。
## 目标 ## 目标
服务端后端不再继续把所有配置、鉴权、审计、页面入口和业务接口堆在 `main.py` 里,而是逐步改成成熟 FastAPI 后台项目常见的分层结构。 服务端后端不再继续把所有配置、鉴权、审计、页面入口和业务接口堆在 `main.py` 里,而是逐步改成成熟 FastAPI 后台项目常见的分层结构。
参考方向 当前采用的成熟框架和模板生态
- 后端 HTTP 框架:FastAPI。
- 运行服务:Uvicorn。
- 请求模型和参数校验:FastAPI 自带的 Pydantic 体系。
- 管理后台前端:GitHub 上的 pure-admin-thin / vue-pure-admin 生态,技术栈是 Vue3、Element Plus、TypeScript、Vite。
- 前端包管理:统一使用 npm 和 package-lock.json,不再维护 pnpm-lock.yaml。
- 对象存储:MinIO。
- 自动化测试:pytest。测试用例会在临时目录里创建隔离数据库、临时 Manifest 私钥、临时上传目录和崩溃报告目录。
后端工程化参考方向:
- FastAPI 官方 full-stack 模板:后端工程结构、配置、JWT、数据库模型、迁移、Docker。 - FastAPI 官方 full-stack 模板:后端工程结构、配置、JWT、数据库模型、迁移、Docker。
- FastAPI Users:用户、认证、JWT、密码哈希、用户管理。 - FastAPI Users:用户、认证、JWT、密码哈希、用户管理。
- 现有 SimCAE 业务:版本发布、Manifest 签名、License、设备、崩溃报告、日志审计。 - 现有 SimCAE 业务:版本发布、Manifest 签名、License、设备、崩溃报告、日志审计。
当前说明:
- 目前不是把某个 GitHub 后端模板整套照搬进项目,而是采用 FastAPI 作为成熟后端框架,并按成熟 FastAPI 后台项目的常见结构做工程化拆分。
- 管理后台前端已经基于 pure-admin-thin 这类成熟后台模板改造。
- 管理员鉴权已经从单一 `ADMIN_TOKEN` 升级为用户名/密码登录、Argon2 密码哈希、JWT access/refresh token、RBAC 权限检查和管理员用户管理页面。管理后台接口不再保留旧的单令牌登录入口;`ADMIN_TOKEN` 仍作为服务端兜底令牌和崩溃报告管理兜底令牌保留。
## 当前已落地 ## 当前已落地
已经新增模板化目录: 已经新增模板化目录:
@@ -19,6 +37,7 @@ app/
api/ api/
router.py router.py
routes/ routes/
admin_auth.py
admin_license.py admin_license.py
admin_policy.py admin_policy.py
admin_version.py admin_version.py
@@ -37,6 +56,7 @@ app/
audit.py audit.py
db/ db/
repositories/ repositories/
admin_user_repository.py
app_channel_repository.py app_channel_repository.py
crash_report_repository.py crash_report_repository.py
device_repository.py device_repository.py
@@ -47,6 +67,7 @@ app/
client_update_repository.py client_update_repository.py
publish_repository.py publish_repository.py
schemas/ schemas/
admin_auth.py
license.py license.py
policy.py policy.py
version.py version.py
@@ -68,7 +89,7 @@ app/
当前已经从 `main.py` 抽出的通用能力: 当前已经从 `main.py` 抽出的通用能力:
- `app/core/config.py`:统一读取 `.env`、路径配置和基础 settings。 - `app/core/config.py`:统一读取 `.env`、路径配置和基础 settings。
- `app/core/security.py`后台令牌校验、令牌摘要、`.env` 写入工具。 - `app/core/security.py`管理员 JWT、Argon2 密码哈希、RBAC 权限检查、令牌摘要、`.env` 写入工具。
- `app/core/audit.py`:管理后台写操作审计中间件。 - `app/core/audit.py`:管理后台写操作审计中间件。
- `app/api/routes/frontend.py`:管理后台静态页面、favicon、platform-config、health 路由。 - `app/api/routes/frontend.py`:管理后台静态页面、favicon、platform-config、health 路由。
- `app/api/routes/admin_license.py`:License 创建、列表、禁用/启用、软删除接口。 - `app/api/routes/admin_license.py`:License 创建、列表、禁用/启用、软删除接口。
@@ -78,7 +99,8 @@ app/
- `app/api/routes/admin_logs.py`:升级日志、下载日志、管理员审计日志的列表、删除和清空接口。 - `app/api/routes/admin_logs.py`:升级日志、下载日志、管理员审计日志的列表、删除和清空接口。
- `app/api/routes/admin_crash_report.py`:崩溃报告管理列表和附件下载接口。 - `app/api/routes/admin_crash_report.py`:崩溃报告管理列表和附件下载接口。
- `app/api/routes/admin_device.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/admin_publish.py`:管理端发布新版本接口,包含发布锁和管理员鉴权。
- `app/api/routes/client_update.py`:客户端设备登记、更新检测、下载链接、Manifest、下载日志和升级结果上报接口。 - `app/api/routes/client_update.py`:客户端设备登记、更新检测、下载链接、Manifest、下载日志和升级结果上报接口。
- `app/api/routes/crash_api.py`:崩溃报告健康检查、报告上传/下载和符号包上传接口。 - `app/api/routes/crash_api.py`:崩溃报告健康检查、报告上传/下载和符号包上传接口。
@@ -93,21 +115,47 @@ app/
- `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。 - `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。
- `app/api/router.py`:统一注册基础路由。 - `app/api/router.py`:统一注册基础路由。
- `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。 - `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。
- `tests/`:自动化测试入口,覆盖管理员登录/JWT、License/设备登记、更新检查、版本发布、崩溃报告上传幂等和管理员查询。
当前业务接口路径保持不变,避免影响 Launcher、Updater 和已部署管理页面。 当前业务接口路径保持不变,避免影响 Launcher、Updater 和已部署管理页面。
## 自动化测试
第一次运行测试前安装开发依赖:
```bash
cd update-server
./venv/bin/pip install -r requirements-dev.txt
```
运行测试:
```bash
./venv/bin/python3 -m pytest -q
```
当前测试不连接真实 MinIO,也不使用真实 `mini.db`。测试夹具会自动创建临时数据库和临时目录,并生成临时 RSA 私钥,因此适合在提交前快速回归。
当前覆盖范围:
- 管理员默认账号初始化、用户名密码登录、JWT 签发。
- 应用创建、License 创建、设备登记和设备数占用。
- 客户端更新检查和版本策略响应。
- 版本发布事务、文件 hash 入库、MinIO 不可用时本地存储回退。
- 崩溃报告上传、同一 `clientReportId` 幂等重复上传、管理员查询。
## 后续迁移路线 ## 后续迁移路线
后续不建议一次性重写全部后端,而是按下面顺序迁移: 后续不建议一次性重写全部后端,而是按下面顺序迁移:
1. 接入成熟用户认证模块,例如 FastAPI Users。 1. 继续接入更完整的成熟用户认证模块,例如 FastAPI Users,替换当前轻量用户表
2. 将 `ADMIN_TOKEN` 替换成管理员用户名/密码登录、JWT、refresh token 2. 将现有权限点继续细化,例如 `version:publish``license:create``audit:read`
3. 增加用户、角色、权限、菜单权限、API 权限表 3. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/`
4. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/` 4. 继续将请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型
5. 继续请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型 5. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰
6. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰 6. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层
7. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层 7. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic
8. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic 8. 增加登录失败审计、登录限流、会话管理和更细粒度 Token 轮换
9. 保持客户端接口 `/api/v1/...` 尽量兼容,避免客户端 SDK 重编。 9. 保持客户端接口 `/api/v1/...` 尽量兼容,避免客户端 SDK 重编。
## 为什么不是直接删除 main.py ## 为什么不是直接删除 main.py
-41
View File
@@ -1,41 +0,0 @@
服务端文档入口
==============
本目录用于保存 update-server 服务端子仓库的说明文档。
建议阅读顺序:
1. 服务端部署说明.md
面向部署和运维,说明 Docker 离线包里有什么、怎么部署、.env 每个字段怎么填、怎么查看日志和备份数据。
2. 后端模板化改造说明.md
面向后端维护,说明当前 FastAPI 后端如何按 routes/services/repositories/schemas 分层,以及后续工程化方向。
3. 崩溃报告接口联调说明.md
面向测试和客户端接入,说明 CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN、CRASH_ADMIN_TOKEN 分别怎么用,并给出可直接复制的 curl 示例。
常用源码入口:
- main.py:服务启动、数据库初始化、MinIO 初始化、全局客户端鉴权。
- app/api/routes/:管理后台、客户端更新、崩溃报告等 HTTP 路由。
- app/services/:业务逻辑。
- app/repositories/SQLite 数据库读写。
- admin-ui/:新版管理后台前端源码。
- legacy/:旧版单文件管理后台,仅作为兼容 fallback。
- scripts/package-offline-server.sh:生成服务端 Docker 离线部署包。
本地源码启动:
```bash
cd update-server
./venv/bin/python3 main.py
```
Docker 离线包打包:
```bash
cd update-server
bash ./scripts/package-offline-server.sh \
--version 0.1.0 \
--output-dir ./dist/SimCAEServerDockerPackage
```
+5 -7
View File
@@ -1,20 +1,18 @@
FROM node:20-alpine as build-stage FROM node:22-alpine as build-stage
WORKDIR /app WORKDIR /app
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
RUN npm config set registry https://registry.npmmirror.com RUN npm config set registry https://registry.npmmirror.com
COPY .npmrc package.json pnpm-lock.yaml ./ COPY .npmrc package.json package-lock.json ./
RUN pnpm install --frozen-lockfile RUN npm ci
COPY . . COPY . .
RUN pnpm build RUN npm run build
FROM nginx:stable-alpine as production-stage FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
-39
View File
@@ -1,39 +0,0 @@
<h1>vue-pure-admin Lite Editionno i18n version</h1>
[![license](https://img.shields.io/github/license/pure-admin/vue-pure-admin.svg)](LICENSE)
**English** | [中文](./README.md)
## Introduce
The simplified version is based on the shelf extracted from [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin), which contains main functions and is more suitable for actual project development. The packaged size is introduced globally [element-plus](https://element-plus.org) is still below `2.3MB`, and the full version of the code will be permanently synchronized. After enabling `brotli` compression and `cdn` to replace the local library mode, the package size is less than `350kb`
## Supporting video
[Click me to view UI design](https://www.bilibili.com/video/BV17g411T7rq)
[Click me to view the rapid development tutorial](https://www.bilibili.com/video/BV1kg411v7QT)
## Nanny-level documents
[Click me to view vue-pure-admin documentation](https://pure-admin.cn/)
[Click me to view @pureadmin/utils documentation](https://pure-admin-utils.netlify.app)
## Premium service
[Click me to view details](https://pure-admin.cn/pages/service/)
## Preview
[Click me to view the preview station](https://pure-admin-thin.netlify.app/#/login)
## Maintainer
[xiaoxian521](https://github.com/xiaoxian521)
## ⚠️ Attention
The Lite version does not accept any issues and prs. If you have any questions, please go to the full version [issues](https://github.com/pure-admin/vue-pure-admin/issues/new/choose) to mention, thank you!
## License
[MIT © 2020-present, pure-admin](./LICENSE)
+22 -31
View File
@@ -1,43 +1,34 @@
<h1>vue-pure-admin精简版(非国际化版本)</h1> # SimCAE 管理后台
[![license](https://img.shields.io/github/license/pure-admin/vue-pure-admin.svg)](LICENSE) 这个目录是服务端管理后台前端,基于 `pure-admin-thin`、Vue 3、Vite、Element Plus 和 TypeScript 改造。
**中文** | [English](./README.en-US.md) ## 主要功能
## 介绍 - 应用、渠道、版本发布和更新策略管理。
- License 授权、设备、升级日志、下载日志和审计日志管理。
- 崩溃报告接口联调信息展示。
- 管理员登录、JWT 会话和角色权限基础能力。
精简版是基于 [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin) 提炼出的架子,包含主体功能,更适合实际项目开发,打包后的大小在全局引入 [element-plus](https://element-plus.org) 的情况下仍然低于 `2.3MB`,并且会永久同步完整版的代码。开启 `brotli` 压缩和 `cdn` 替换本地库模式后,打包大小低于 `350kb` ## 本地开发
## 版本选择 ```bash
npm ci
npm run dev
```
当前是非国际化版本,如果您需要国际化版本 [请点击](https://github.com/pure-admin/pure-admin-thin/tree/i18n) ## 构建
## 配套视频 ```bash
npm ci
npm run build
```
[点我查看 UI 设计](https://www.bilibili.com/video/BV17g411T7rq) 构建产物输出到 `dist/`。服务端 Dockerfile 会自动构建并复制 `dist/`,通常不需要手动复制。
[点我查看快速开发教程](https://www.bilibili.com/video/BV1kg411v7QT)
## 配套保姆级文档 ## 包管理约定
[点我查看 vue-pure-admin 文档](https://pure-admin.cn/) 本项目统一使用 `npm``package-lock.json`。不要再提交 `pnpm-lock.yaml``yarn.lock`,避免不同包管理器解析出不同依赖版本。
[点我查看 @pureadmin/utils 文档](https://pure-admin-utils.netlify.app)
## 高级服务 ## 模板来源
[点我查看详情](https://pure-admin.cn/pages/service/) 初始工程来自开源后台模板 `pure-admin-thin`。当前仓库只保留 SimCAE 后台需要的页面和运行能力,示例页面不作为业务交付内容。
## 预览
[查看预览](https://pure-admin-thin.netlify.app/#/login)
## 维护者
[xiaoxian521](https://github.com/xiaoxian521)
## ⚠️ 注意
精简版不接受任何 `issues``pr`,如果有问题请到完整版 [issues](https://github.com/pure-admin/vue-pure-admin/issues/new/choose) 去提,谢谢!
## 许可证
[MIT © 2020-present, pure-admin](./LICENSE)
-7387
View File
File diff suppressed because it is too large Load Diff
+23 -5
View File
@@ -1,4 +1,4 @@
import { getToken } from "@/utils/auth"; import { formatToken, getToken, removeToken } from "@/utils/auth";
export class AdminApiError extends Error { export class AdminApiError extends Error {
status: number; status: number;
@@ -13,7 +13,14 @@ export class AdminApiError extends Error {
} }
export function adminToken() { 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));
}
} }
export function friendlyError(error: unknown) { export function friendlyError(error: unknown) {
@@ -37,8 +44,7 @@ export async function adminRequest<T = any>(
} = {} } = {}
): Promise<T> { ): Promise<T> {
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
const token = adminToken(); applyAdminAuth(headers);
if (token) headers.set("X-Admin-Token", token);
let body = options.body; let body = options.body;
if (options.json !== undefined) { if (options.json !== undefined) {
@@ -64,6 +70,12 @@ export async function adminRequest<T = any>(
} }
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
removeToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new AdminApiError( throw new AdminApiError(
`HTTP ${response.status}: ${text || response.statusText}`, `HTTP ${response.status}: ${text || response.statusText}`,
response.status, response.status,
@@ -100,7 +112,7 @@ export async function downloadAdminFile(
} = {} } = {}
) { ) {
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
headers.set("X-Admin-Token", adminToken()); applyAdminAuth(headers);
let body = options.body; let body = options.body;
if (options.json !== undefined) { if (options.json !== undefined) {
@@ -116,6 +128,12 @@ export async function downloadAdminFile(
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
if (response.status === 401) {
removeToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new AdminApiError( throw new AdminApiError(
`HTTP ${response.status}: ${text || response.statusText}`, `HTTP ${response.status}: ${text || response.statusText}`,
response.status, response.status,
+3 -1
View File
@@ -33,6 +33,7 @@ import {
import { import {
type DataInfo, type DataInfo,
userKey, userKey,
getToken,
removeToken, removeToken,
multipleTabsKey multipleTabsKey
} from "@/utils/auth"; } from "@/utils/auth";
@@ -147,7 +148,8 @@ router.beforeEach((to: ToRouteType, _from, next) => {
function toCorrectRoute() { function toCorrectRoute() {
whiteList.includes(to.fullPath) ? next(_from.fullPath) : next(); whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
} }
if (Cookies.get(multipleTabsKey) && userInfo) { const accessToken = getToken()?.accessToken;
if (Cookies.get(multipleTabsKey) && userInfo && accessToken) {
// 无权限跳转403页面 // 无权限跳转403页面
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) { if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
next({ path: "/error/403" }); next({ path: "/error/403" });
-59
View File
@@ -1,59 +0,0 @@
import { removeToken, setToken, type DataInfo } from "./auth";
import { subBefore, getQueryMap } from "@pureadmin/utils";
/**
* 简版前端单点登录,根据实际业务自行编写,平台启动后本地可以跳后面这个链接进行测试 http://localhost:8848/#/permission/page/index?username=sso&roles=admin&accessToken=eyJhbGciOiJIUzUxMiJ9.admin
* 划重点:
* 判断是否为单点登录,不为则直接返回不再进行任何逻辑处理,下面是单点登录后的逻辑处理
* 1.清空本地旧信息;
* 2.获取url中的重要参数信息,然后通过 setToken 保存在本地;
* 3.删除不需要显示在 url 的参数
* 4.使用 window.location.replace 跳转正确页面
*/
(function () {
// 获取 url 中的参数
const params = getQueryMap(location.href) as DataInfo<Date>;
const must = ["username", "roles", "accessToken"];
const mustLength = must.length;
if (Object.keys(params).length !== mustLength) return;
// url 参数满足 must 里的全部值,才判定为单点登录,避免非单点登录时刷新页面无限循环
let sso = [];
let start = 0;
while (start < mustLength) {
if (Object.keys(params).includes(must[start]) && sso.length <= mustLength) {
sso.push(must[start]);
} else {
sso = [];
}
start++;
}
if (sso.length === mustLength) {
// 判定为单点登录
// 清空本地旧信息
removeToken();
// 保存新信息到本地
setToken(params);
// 删除不需要显示在 url 的参数
delete params.roles;
delete params.accessToken;
const newUrl = `${location.origin}${location.pathname}${subBefore(
location.hash,
"?"
)}?${JSON.stringify(params)
.replace(/["{}]/g, "")
.replace(/:/g, "=")
.replace(/,/g, "&")}`;
// 替换历史记录项
window.location.replace(newUrl);
} else {
return;
}
})();
+35 -32
View File
@@ -11,12 +11,13 @@ import { useLayout } from "@/layout/hooks/useLayout";
import { initRouter, getTopMenu } from "@/router/utils"; import { initRouter, getTopMenu } from "@/router/utils";
import { bg, avatar, illustration } from "./utils/static"; import { bg, avatar, illustration } from "./utils/static";
import { useRenderIcon } from "@/components/ReIcon/src/hooks"; import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { setToken } from "@/utils/auth";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange"; import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import dayIcon from "@/assets/svg/day.svg?component"; import dayIcon from "@/assets/svg/day.svg?component";
import darkIcon from "@/assets/svg/dark.svg?component"; import darkIcon from "@/assets/svg/dark.svg?component";
import Lock from "~icons/ri/lock-fill"; import Lock from "~icons/ri/lock-fill";
import User from "~icons/ri/user-3-fill";
import { useUserStoreHook } from "@/store/modules/user";
defineOptions({ defineOptions({
name: "Login" name: "Login"
@@ -33,50 +34,32 @@ initStorage();
const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange(); const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange();
dataThemeChange(overallStyle.value); dataThemeChange(overallStyle.value);
const { title } = useNav(); const { title } = useNav();
const userStore = useUserStoreHook();
const ruleForm = reactive({ 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) => { const onLogin = async (formEl: FormInstance | undefined) => {
if (!formEl) return; if (!formEl) return;
await formEl.validate(async valid => { await formEl.validate(async valid => {
if (!valid) return; if (!valid) return;
loading.value = true; loading.value = true;
try { try {
const adminToken = ruleForm.adminToken.trim(); const username = ruleForm.username.trim();
await checkAdminToken(adminToken); await userStore.loginByUsername({
localStorage.setItem("admin_token", adminToken); username,
setToken({ password: ruleForm.password
accessToken: adminToken,
refreshToken: adminToken,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
avatar: "",
username: "admin",
nickname: "管理员",
roles: ["admin"],
permissions: ["*:*:*"]
}); });
localStorage.setItem("admin_username", username);
await initRouter(); await initRouter();
disabled.value = true; disabled.value = true;
await router.push(getTopMenu(true).path); await router.push(getTopMenu(true).path);
message("登录成功", { type: "success" }); message("登录成功", { type: "success" });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message("管理员令牌错误或服务端不可访问", { type: "error" }); message("用户名、密码错误或服务端不可访问", { type: "error" });
} finally { } finally {
loading.value = false; loading.value = false;
disabled.value = false; disabled.value = false;
@@ -126,20 +109,40 @@ useEventListener(document, "keydown", ({ code }) => {
<el-form ref="ruleFormRef" :model="ruleForm" size="large"> <el-form ref="ruleFormRef" :model="ruleForm" size="large">
<Motion :delay="100"> <Motion :delay="100">
<el-form-item <el-form-item
prop="adminToken" prop="username"
:rules="[ :rules="[
{ {
required: true, required: true,
message: '请输入管理员令牌', message: '请输入用户名',
trigger: 'blur' trigger: 'blur'
} }
]" ]"
> >
<el-input <el-input
v-model="ruleForm.adminToken" v-model="ruleForm.username"
clearable
placeholder="用户名"
:prefix-icon="useRenderIcon(User)"
/>
</el-form-item>
</Motion>
<Motion :delay="180">
<el-form-item
prop="password"
:rules="[
{
required: true,
message: '请输入密码',
trigger: 'blur'
}
]"
>
<el-input
v-model="ruleForm.password"
clearable clearable
show-password show-password
placeholder="管理员令牌" placeholder="密码"
:prefix-icon="useRenderIcon(Lock)" :prefix-icon="useRenderIcon(Lock)"
/> />
</el-form-item> </el-form-item>
@@ -1,99 +0,0 @@
<script setup lang="ts">
import { hasAuth, getAuths } from "@/router/utils";
defineOptions({
name: "PermissionButtonRouter"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ getAuths() }}</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Auth value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Auth>
<Auth :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Auth>
<Auth
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Auth>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasAuth([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-auth="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-auth="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-auth="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
@@ -1,109 +0,0 @@
<script setup lang="ts">
import { hasPerms } from "@/utils/auth";
import { useUserStoreHook } from "@/store/modules/user";
const { permissions } = useUserStoreHook();
defineOptions({
name: "PermissionButtonLogin"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ permissions }}</p>
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
*:*:* 代表拥有全部按钮级别权限
</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Perms value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Perms>
<Perms :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Perms>
<Perms
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Perms>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button
v-if="hasPerms(['permission:btn:edit'])"
plain
type="primary"
>
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasPerms([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-perms="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-perms="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-perms="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { initRouter } from "@/router/utils";
import { storageLocal } from "@pureadmin/utils";
import { type CSSProperties, ref, computed } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { usePermissionStoreHook } from "@/store/modules/permission";
defineOptions({
name: "PermissionPage"
});
const elStyle = computed((): CSSProperties => {
return {
width: "85vw",
justifyContent: "start"
};
});
const username = ref(useUserStoreHook()?.username);
const options = [
{
value: "admin",
label: "管理员角色"
},
{
value: "common",
label: "普通角色"
}
];
function onChange() {
useUserStoreHook()
.loginByUsername({ username: username.value, password: "admin123" })
.then(res => {
if (res.success) {
storageLocal().removeItem("async-routes");
usePermissionStoreHook().clearAllCachePage();
initRouter();
}
});
}
</script>
<template>
<div>
<p class="mb-2!">
模拟后台根据不同角色返回对应路由观察左侧菜单变化管理员角色可查看系统管理菜单普通角色不可查看系统管理菜单
</p>
<el-card shadow="never" :style="elStyle">
<template #header>
<div class="card-header">
<span>当前角色{{ username }}</span>
</div>
</template>
<el-select v-model="username" class="w-[160px]!" @change="onChange">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-card>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
export type UploadEntry = { file: File; path: string };
export type PublishManifestRule = {
line: number;
raw: string;
pattern: string;
exclude: boolean;
};
export const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
export function rawPathForFile(file: File) {
const raw = ((file as any).webkitRelativePath || file.name) as string;
return raw.replaceAll("\\", "/");
}
export 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("/");
}
export 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 || "*";
}
export 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, "\\$&");
}
export 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);
}
export function isReleaseManifestFile(path: string) {
const normalized = normalizePublishPath(path).toLowerCase();
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
}
export 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")
);
}
export 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("/");
}
export 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}`;
}
export 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;
}
export function executableLeafName(value: string, fallback: string) {
return (
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback
);
}
export function dirname(path: string) {
const parts = normalizePublishPath(path).split("/").filter(Boolean);
parts.pop();
return parts.join("/");
}
export function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
return entries
.map(item => normalizePublishPath(item.path))
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
}
+4
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from app.api.routes import ( from app.api.routes import (
admin_app_channel, admin_app_channel,
admin_auth,
admin_config, admin_config,
admin_crash_report, admin_crash_report,
admin_device, admin_device,
@@ -13,6 +14,7 @@ from app.api.routes import (
client_update, client_update,
crash_api, crash_api,
frontend, frontend,
git_tags,
) )
@@ -20,6 +22,7 @@ def include_api_routes(app: FastAPI):
frontend.mount_admin_assets(app) frontend.mount_admin_assets(app)
crash_api.register_exception_handlers(app) crash_api.register_exception_handlers(app)
app.include_router(frontend.router) app.include_router(frontend.router)
app.include_router(admin_auth.router)
app.include_router(admin_policy.router) app.include_router(admin_policy.router)
app.include_router(admin_version.router) app.include_router(admin_version.router)
app.include_router(admin_license.router) app.include_router(admin_license.router)
@@ -29,5 +32,6 @@ def include_api_routes(app: FastAPI):
app.include_router(admin_logs.router) app.include_router(admin_logs.router)
app.include_router(admin_crash_report.router) app.include_router(admin_crash_report.router)
app.include_router(admin_device.router) app.include_router(admin_device.router)
app.include_router(git_tags.router)
app.include_router(client_update.router) app.include_router(client_update.router)
app.include_router(crash_api.router) app.include_router(crash_api.router)
+5 -5
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException 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.repositories import app_channel_repository
from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest
from app.services.common_service import validate_channel_code from app.services.common_service import validate_channel_code
@@ -10,12 +10,12 @@ router = APIRouter(tags=["admin-app-channel"])
@router.get("/admin/app/list") @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()} return {"list": app_channel_repository.list_apps()}
@router.post("/admin/app/add") @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 aid = body.app_id
aname = body.app_name aname = body.app_name
try: try:
@@ -26,12 +26,12 @@ def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
@router.get("/admin/channel/list") @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)} return {"list": app_channel_repository.list_channels(app_id, include_disabled)}
@router.post("/admin/channel/save") @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() app_id = body.app_id.strip()
code = body.channel_code.strip() code = body.channel_code.strip()
name = body.display_name.strip() name = body.display_name.strip()
+246
View File
@@ -0,0 +1,246 @@
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")),
):
new_password = body.new_password.strip()
if len(new_password) < 8:
raise HTTPException(status_code=400, detail="新密码至少需要 8 个字符")
if len(new_password) > 128:
raise HTTPException(status_code=400, detail="新密码不能超过 128 个字符")
row = admin_user_repository.get_user(principal.username)
if not row or not verify_password(body.current_password, row["password_hash"]):
raise HTTPException(status_code=403, detail="当前密码错误")
admin_user_repository.update_password(principal.username, hash_password(new_password))
return {"code": 0, "msg": "密码已更新,请重新登录"}
+21 -12
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from app.core.config import ENV_FILE_PATH from app.core.config import ENV_FILE_PATH
from app.core.security import admin_auth, persist_env_value, set_admin_token, 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.repositories import app_channel_repository, license_repository
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
from app.services.admin_config_service import ( from app.services.admin_config_service import (
@@ -53,29 +53,33 @@ def client_config_license_warning(license_key: str, app_id: str, channel: str) -
return "" return ""
@router.get("/admin/auth/check") def normalize_install_root(value: str, fallback: str = "..") -> str:
def admin_auth_check(auth=Depends(admin_auth)): normalized = str(value or fallback).strip().replace("\\", "/")
return {"code": 0, "msg": "管理员令牌有效"} if normalized in ("", "."):
return "."
if normalized == "..":
return ".."
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
@router.post("/admin/token/change") @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() new_token = body.new_token.strip()
if len(new_token) < 8: if len(new_token) < 8:
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符") raise HTTPException(status_code=400, detail="服务端兜底令牌至少需要 8 个字符")
if len(new_token) > 128: if len(new_token) > 128:
raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符") raise HTTPException(status_code=400, detail="服务端兜底令牌不能超过 128 个字符")
try: try:
persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token) persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}") raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}")
set_admin_token(new_token) set_admin_token(new_token)
os.environ["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") @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 { return {
"release_main_executable": RELEASE_MAIN_EXECUTABLE, "release_main_executable": RELEASE_MAIN_EXECUTABLE,
"target_platform": TARGET_PLATFORM, "target_platform": TARGET_PLATFORM,
@@ -89,7 +93,7 @@ def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
@router.post("/admin/client-config/generate") @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() app_id = body.app_id.strip()
channel = body.channel.strip() or "stable" channel = body.channel.strip() or "stable"
current_version = body.current_version.strip() or "1.0.0" 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} 不存在"}) raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
defaults = default_client_config_values(request) defaults = default_client_config_values(request)
install_root = normalize_install_root(body.install_root, defaults["install_root"])
main_executable = platform_executable_path( main_executable = platform_executable_path(
normalize_executable_name(body.main_executable), normalize_executable_name(body.main_executable),
defaults["main_executable"], defaults["main_executable"],
@@ -126,12 +131,11 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
"client_protocol": str(client_protocol), "client_protocol": str(client_protocol),
"launch_token": defaults["launch_token"], "launch_token": defaults["launch_token"],
"license_key": license_key, "license_key": license_key,
"api_base_url": api_base_url,
"client_token": defaults["client_token"], "client_token": defaults["client_token"],
"request_timeout_ms": defaults["request_timeout_ms"], "request_timeout_ms": defaults["request_timeout_ms"],
"temp_folder": defaults["temp_folder"], "temp_folder": defaults["temp_folder"],
"device_id": "", "device_id": "",
"install_root": defaults["install_root"], "install_root": install_root,
"main_executable": main_executable, "main_executable": main_executable,
"launcher_executable": defaults["launcher_executable"], "launcher_executable": defaults["launcher_executable"],
"updater_executable": defaults["updater_executable"], "updater_executable": defaults["updater_executable"],
@@ -141,10 +145,15 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
"arch": defaults["arch"], "arch": defaults["arch"],
} }
crash_test_config = crash_report_test_values(api_base_url) crash_test_config = crash_report_test_values(api_base_url)
server_config = {
"api_base_url": api_base_url,
}
return { return {
"config": config, "config": config,
"json_text": json.dumps(config, ensure_ascii=False, indent=2), "json_text": json.dumps(config, ensure_ascii=False, indent=2),
"license_warning": license_warning, "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_config": crash_test_config,
"crash_test_text": crash_report_test_text(api_base_url), "crash_test_text": crash_report_test_text(api_base_url),
} }
+4 -5
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, Header, HTTPException, Request from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import FileResponse 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 from app.repositories import crash_report_repository
@@ -11,7 +11,7 @@ router = APIRouter(tags=["admin-crash-report"])
@router.get("/admin/crash-report/list") @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)) limit = max(1, min(limit, 1000))
return {"list": crash_report_repository.list_crash_reports(limit)} return {"list": crash_report_repository.list_crash_reports(limit)}
@@ -21,8 +21,7 @@ def admin_crash_report_file(
report_id: str, report_id: str,
file_name: str, file_name: str,
request: Request, request: Request,
X_Admin_Token: str = Header(""), auth: AdminPrincipal = Depends(require_permission("crash:view")),
auth=Depends(admin_auth),
): ):
allowed = { allowed = {
"metadata": "metadata.json", "metadata": "metadata.json",
@@ -49,7 +48,7 @@ def admin_crash_report_file(
crash_report_repository.log_file_access( crash_report_repository.log_file_access(
report_id, report_id,
stored_name, stored_name,
token_digest(X_Admin_Token)[:16], auth.actor_hash,
request.client.host if request.client else "", request.client.host if request.client else "",
request.headers.get("user-agent", "")[:300], request.headers.get("user-agent", "")[:300],
) )
+3 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException 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.repositories import device_repository
from app.schemas.device import DeviceSetDisabledRequest from app.schemas.device import DeviceSetDisabledRequest
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-device"])
@router.get("/admin/device/list") @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)} return {"list": device_repository.list_devices(app_id)}
@router.post("/admin/device/set-disabled") @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() device_id = body.device_id.strip()
disabled = bool(body.disabled) disabled = bool(body.disabled)
reason = body.reason.strip() reason = body.reason.strip()
+5 -5
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException 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.repositories import license_repository
from app.schemas.license import LicenseCreateRequest, LicenseDeleteRequest, LicenseStatusRequest from app.schemas.license import LicenseCreateRequest, LicenseDeleteRequest, LicenseStatusRequest
from app.services.common_service import validate_channel_code from app.services.common_service import validate_channel_code
@@ -14,7 +14,7 @@ router = APIRouter(tags=["admin-license"])
@router.post("/admin/license/create") @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() app_id = body.app_id.strip()
channel = body.channel.strip() channel = body.channel.strip()
customer = body.customer_name.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") @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) rows = license_repository.list_licenses(app_id, include_deleted)
result = [] result = []
for r in rows: 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") @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 status = body.status
license_id = body.license_id license_id = body.license_id
if status not in ("active", "disabled"): 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") @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() license_id = body.license_id.strip()
if not license_id: if not license_id:
raise HTTPException(status_code=400, detail="license_id 不能为空") raise HTTPException(status_code=400, detail="license_id 不能为空")
+10 -10
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Body, Depends, HTTPException from 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.repositories import log_repository
from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-logs"])
@router.get("/admin/report/list") @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()} return {"list": log_repository.list_upgrade_logs()}
@router.post("/admin/report/delete") @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) log_id = int(body.id or 0)
if log_id < 1: if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效") 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") @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() deleted = log_repository.clear_upgrade_logs()
return {"msg": "升级日志已清空", "deleted": deleted} return {"msg": "升级日志已清空", "deleted": deleted}
@router.get("/admin/download-log/list") @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)) limit = max(1, min(limit, 1000))
return {"list": log_repository.list_download_logs(app_id, limit)} return {"list": log_repository.list_download_logs(app_id, limit)}
@router.post("/admin/download-log/delete") @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) log_id = int(body.id or 0)
if log_id < 1: if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效") 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") @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() app_id = (body.app_id if body else "").strip()
deleted = log_repository.clear_download_logs(app_id) deleted = log_repository.clear_download_logs(app_id)
return {"msg": "下载日志已清空", "deleted": deleted} return {"msg": "下载日志已清空", "deleted": deleted}
@router.get("/admin/audit-log/list") @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)))} return {"list": log_repository.list_audit_logs(max(1, min(limit, 1000)))}
@router.post("/admin/audit-log/delete") @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) log_id = int(body.id or 0)
if log_id < 1: if log_id < 1:
raise HTTPException(status_code=400, detail="日志 ID 无效") 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") @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() deleted = log_repository.clear_audit_logs()
return {"msg": "审计日志已清空", "deleted": deleted} return {"msg": "审计日志已清空", "deleted": deleted}
+4 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException 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.repositories import policy_repository
from app.schemas.policy import PolicySaveRequest from app.schemas.policy import PolicySaveRequest
from app.services.common_service import policy_row_to_dict, validate_channel_code 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") @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) row = policy_repository.get_policy(app_id, channel)
result = policy_row_to_dict(row) result = policy_row_to_dict(row)
result.update({"app_id": app_id, "channel": channel, "saved": bool(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") @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() app_id = body.app_id.strip()
channel = body.channel.strip() channel = body.channel.strip()
if not app_id or not validate_channel_code(channel): if not app_id or not validate_channel_code(channel):
@@ -43,6 +43,7 @@ def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
valid_until, valid_until,
body.min_supported_version.strip(), body.min_supported_version.strip(),
json.dumps(disabled, ensure_ascii=False), json.dumps(disabled, ensure_ascii=False),
bool(body.git_tags_enabled),
body.message.strip(), body.message.strip(),
) )
if result == "channel_not_found": if result == "channel_not_found":
+77 -2
View File
@@ -1,18 +1,93 @@
import asyncio import asyncio
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request 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 from app.services import publish_service
router = APIRouter() router = APIRouter()
PUBLISH_LOCK = asyncio.Lock() PUBLISH_LOCK = asyncio.Lock()
PUBLISH_JOBS: dict[str, dict] = {}
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def public_job(job: dict):
return {
"job_id": job["job_id"],
"status": job["status"],
"message": job["message"],
"result": job.get("result"),
"error": job.get("error"),
"created_at": job["created_at"],
"updated_at": job["updated_at"],
}
async def run_publish_job(job_id: str, payload: dict):
job = PUBLISH_JOBS[job_id]
job.update(status="running", message="正在校验并发布版本", updated_at=utc_now_iso())
try:
result = await publish_service.publish_payload(payload)
job.update(
status="success",
message=result.get("msg") or "发布完成",
result=result,
updated_at=utc_now_iso(),
)
except HTTPException as err:
detail = err.detail if isinstance(err.detail, str) else str(err.detail)
job.update(status="error", message=detail, error=err.detail, updated_at=utc_now_iso())
except Exception as err:
job.update(status="error", message=f"发布失败:{err}", error=str(err), updated_at=utc_now_iso())
finally:
if PUBLISH_LOCK.locked():
PUBLISH_LOCK.release()
@router.post("/admin/publish") @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"))):
# 发布版本会写数据库、上传大量文件到 MinIO,并可能持续较长时间。
# 同一时间只允许一个发布任务,避免两个版本并发写入导致目录、版本号或文件清单互相污染。
if PUBLISH_LOCK.locked(): if PUBLISH_LOCK.locked():
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布") raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
async with PUBLISH_LOCK: async with PUBLISH_LOCK:
return await publish_service.publish_version(request) return await publish_service.publish_version(request)
@router.post("/admin/publish/job")
async def admin_publish_version_job(request: Request, auth=Depends(require_permission("publish:manage"))):
if PUBLISH_LOCK.locked():
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
await PUBLISH_LOCK.acquire()
try:
payload = await publish_service.make_background_publish_payload(request)
job_id = secrets.token_urlsafe(16)
PUBLISH_JOBS[job_id] = {
"job_id": job_id,
"status": "queued",
"message": "发布文件已接收,等待后台处理",
"result": None,
"error": None,
"created_at": utc_now_iso(),
"updated_at": utc_now_iso(),
}
asyncio.create_task(run_publish_job(job_id, payload))
return public_job(PUBLISH_JOBS[job_id])
except Exception:
if PUBLISH_LOCK.locked():
PUBLISH_LOCK.release()
raise
@router.get("/admin/publish/job/{job_id}")
async def admin_publish_job_status(job_id: str, auth=Depends(require_permission("publish:manage"))):
job = PUBLISH_JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="发布任务不存在或服务端已重启")
return public_job(job)
+6 -6
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
import minio_tool 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.repositories import version_repository
from app.schemas.version import VersionIdRequest, VersionProtocolRequest from app.schemas.version import VersionIdRequest, VersionProtocolRequest
from app.services.common_service import is_executable_path from app.services.common_service import is_executable_path
@@ -22,7 +22,7 @@ router = APIRouter(tags=["admin-version"])
@router.post("/admin/version/offline-package") @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) version_id = int(body.version_id or 0)
ver, rows = version_repository.get_version_with_files(version_id) ver, rows = version_repository.get_version_with_files(version_id)
if not ver or not rows: 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") @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)} return {"list": version_repository.list_versions(app_id)}
@router.post("/admin/version/set-protocol") @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) version_id = int(body.version_id or 0)
client_protocol = int(body.client_protocol or 0) client_protocol = int(body.client_protocol or 0)
if version_id < 1 or client_protocol < 1: 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") @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 vid = body.version_id
result = version_repository.set_latest_version(vid) result = version_repository.set_latest_version(vid)
if result == "not_found": if result == "not_found":
@@ -113,7 +113,7 @@ def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
@router.post("/admin/version/delete") @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 vid = body.version_id
v_info = version_repository.get_version_for_delete(vid) v_info = version_repository.get_version_for_delete(vid)
if not v_info: if not v_info:
+9
View File
@@ -45,6 +45,8 @@ def normalize_relative_path(raw_path: str) -> str:
@router.post("/api/v1/device/issue") @router.post("/api/v1/device/issue")
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)): async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
# 设备登记:客户端首次启动时用 License 换取服务端签名的设备凭证。
# 后续更新接口依赖这个凭证里的 app_id/channel/device_id/license_id,而不是只相信客户端自报字段。
app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip() app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip()
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128): if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
raise HTTPException(status_code=400, detail="设备登记参数无效") raise HTTPException(status_code=400, detail="设备登记参数无效")
@@ -90,6 +92,8 @@ async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
@router.post("/api/v1/update/check") @router.post("/api/v1/update/check")
async def check_update(request: Request, body: CheckUpdateRequest = Body(...)): async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
# 更新检查同时返回“能不能运行”和“要不要更新”。
# 客户端即使没有新版本,也会拿到签名策略,用于离线启动、禁用版本和防回滚判断。
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
@@ -145,6 +149,7 @@ async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
"latest_version": latest_ver, "latest_version": latest_ver,
"min_supported_version": settings["min_supported_version"], "min_supported_version": settings["min_supported_version"],
"disabled_versions": settings["disabled_versions"], "disabled_versions": settings["disabled_versions"],
"git_tags_enabled": settings["git_tags_enabled"],
"message": message, "message": message,
"signature_alg": "RSA-2048-SHA256", "signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID, "key_id": SIGNING_KEY_ID,
@@ -176,6 +181,8 @@ async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
@router.post("/api/v1/update/download-url") @router.post("/api/v1/update/download-url")
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)): async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
# 下载链接不直接暴露 MinIO 永久地址,而是按版本和设备凭证生成短期可用的预签名 URL。
# 这样既能让客户端直接下载大文件,又能保留服务端授权控制。
identity = request.state.device_identity identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel: if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
@@ -213,6 +220,8 @@ async def report_download(request: Request, body: DownloadReportRequest = Body(.
@router.post("/api/v1/update/manifest") @router.post("/api/v1/update/manifest")
async def get_manifest(request: Request, body: ManifestRequest = Body(...)): async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
# Manifest 是某个版本的文件清单:路径、大小、SHA256、是否可执行。
# 客户端必须先验证服务端签名,再按清单下载和校验文件,防止升级包被篡改。
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ver, rows = client_update_repository.get_manifest_version_files(body.version_id) ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
+3
View File
@@ -17,6 +17,8 @@ def crash_health():
@router.post("/api/v1/crash-reports") @router.post("/api/v1/crash-reports")
async def create_crash_report(request: Request): async def create_crash_report(request: Request):
# 崩溃报告接口给 SimCAE/CrashReporter 调用,接收 metadata、minidump 和可选附件。
# 具体 token 校验、大小限制、落盘和幂等处理都集中在 crash_api_service。
return await crash_api_service.create_crash_report(request) return await crash_api_service.create_crash_report(request)
@@ -32,4 +34,5 @@ def download_crash_report_file(report_id: str, file_name: str, request: Request)
@router.post("/api/v1/symbols") @router.post("/api/v1/symbols")
async def upload_symbols(request: Request): async def upload_symbols(request: Request):
# 符号包用于后续解析 minidump 堆栈;它和普通升级文件分开存储、分开授权。
return await crash_api_service.upload_symbols(request) return await crash_api_service.upload_symbols(request)
+3 -14
View File
@@ -7,7 +7,6 @@ from app.core.config import configured_path
router = APIRouter(include_in_schema=False) router = APIRouter(include_in_schema=False)
ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "legacy/admin.html")
ADMIN_UI_DIST_PATH = configured_path("ADMIN_UI_DIST_PATH", "admin-ui/dist") ADMIN_UI_DIST_PATH = configured_path("ADMIN_UI_DIST_PATH", "admin-ui/dist")
ADMIN_UI_INDEX_PATH = ADMIN_UI_DIST_PATH / "index.html" ADMIN_UI_INDEX_PATH = ADMIN_UI_DIST_PATH / "index.html"
ADMIN_UI_ASSETS_PATH = ADMIN_UI_DIST_PATH / "assets" ADMIN_UI_ASSETS_PATH = ADMIN_UI_DIST_PATH / "assets"
@@ -23,20 +22,10 @@ def mount_admin_assets(app: FastAPI):
@router.get("/") @router.get("/")
@router.get("/admin.html")
def admin_page(): def admin_page():
if ADMIN_UI_INDEX_PATH.is_file(): if not ADMIN_UI_INDEX_PATH.is_file():
return FileResponse(ADMIN_UI_INDEX_PATH, headers={"Cache-Control": "no-store"}) raise HTTPException(status_code=404, detail="管理后台前端文件不存在,请先构建 admin-ui/dist")
if not ADMIN_HTML_PATH.is_file(): return FileResponse(ADMIN_UI_INDEX_PATH, headers={"Cache-Control": "no-store"})
raise HTTPException(status_code=404, detail="管理页面文件不存在")
return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"})
@router.get("/legacy-admin.html")
def legacy_admin_page():
if not ADMIN_HTML_PATH.is_file():
raise HTTPException(status_code=404, detail="旧管理页面文件不存在")
return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"})
@router.get("/favicon.ico") @router.get("/favicon.ico")
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from app.core.security import require_permission
from app.repositories import policy_repository
from app.schemas.client_update import GitTagsRequest
from app.services.common_service import policy_row_to_dict
from app.services.git_tags_service import load_git_tags
router = APIRouter(tags=["git-tags"])
@router.get("/admin/git/tags")
def admin_git_tags(
force_refresh: bool = Query(False),
auth=Depends(require_permission("policy:view")),
):
try:
return load_git_tags(force_refresh=force_refresh)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"获取 Git 标签失败:{exc}") from exc
@router.post("/api/v1/git/tags")
def client_git_tags(request: Request, body: GitTagsRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
policy = policy_row_to_dict(policy_repository.get_policy(body.app_id, body.channel))
if not policy["git_tags_enabled"]:
raise HTTPException(
status_code=403,
detail={"error": "git_tags_disabled", "msg": "当前策略未允许生成 Git 标签清单"},
)
try:
payload = load_git_tags(force_refresh=False)
return {
"generated_at": payload["generated_at"],
"repo_count": payload["repo_count"],
"tag_count": payload["tag_count"],
"tags_text": payload["tags_text"],
}
except Exception as exc:
raise HTTPException(status_code=502, detail=f"获取 Git 标签失败:{exc}") from exc
+8 -4
View File
@@ -19,14 +19,18 @@ async def admin_audit_middleware(request: Request, call_next):
finally: finally:
if should_audit: if should_audit:
try: try:
token = request.headers.get("X-Admin-Token", "") principal = getattr(request.state, "admin_principal", None)
token = request.headers.get("authorization", "")
conn = db.get_conn() conn = db.get_conn()
conn.execute( conn.execute(
"""INSERT INTO admin_audit_logs """INSERT INTO admin_audit_logs
(actor_hash,action,method,path,target,result,status_code,ip,user_agent) (actor_hash,actor_username,actor_roles,auth_type,action,method,path,target,result,status_code,ip,user_agent)
VALUES(?,?,?,?,?,?,?,?,?)""", 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.url.path.removeprefix("/admin/"),
request.method, request.method,
request.url.path, request.url.path,
+265 -6
View File
@@ -1,20 +1,235 @@
from pathlib import Path
import hashlib import hashlib
import json
import os
import secrets 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.core.config import ENV_FILE_PATH, settings
from app.repositories import admin_user_repository
if not settings.admin_token: if not settings.admin_token:
raise RuntimeError("请在环境变量或 .env 中配置 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: def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest() 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 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: def quote_env_value(value: str) -> str:
unsafe_chars = set(" \t\n\r#\"'") unsafe_chars = set(" \t\n\r#\"'")
if value and not any(ch in unsafe_chars for ch in value): if value and not any(ch in unsafe_chars for ch in value):
@@ -46,10 +261,54 @@ def persist_env_value(path: Path, key: str, value: str) -> bool:
return True return True
def admin_auth(X_Admin_Token: str = Header("")): def current_admin(
if not secrets.compare_digest(X_Admin_Token, settings.admin_token): request: Request,
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问") Authorization: str = Header(""),
return True ) -> AdminPrincipal:
authorization = Authorization if isinstance(Authorization, 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
raise HTTPException(status_code=401, detail="请先登录管理后台")
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 ADMIN_TOKEN = settings.admin_token
+135
View File
@@ -0,0 +1,135 @@
import db
def list_users():
conn = db.get_conn()
rows = conn.execute(
"""SELECT id,username,display_name,roles,status,created_at,updated_at,last_login_at
FROM admin_users
ORDER BY id ASC"""
).fetchall()
conn.close()
return rows
def has_any_user() -> bool:
conn = db.get_conn()
row = conn.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
conn.close()
return bool(row)
def get_user(username: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM admin_users WHERE username=?", (username,)).fetchone()
conn.close()
return row
def create_user(username: str, password_hash: str, display_name: str, roles: str, status: str = "active") -> None:
conn = db.get_conn()
conn.execute(
"""INSERT INTO admin_users(username,password_hash,display_name,roles,status)
VALUES(?,?,?,?,?)""",
(username, password_hash, display_name, roles, status),
)
conn.commit()
conn.close()
def update_user_profile(username: str, display_name: str, roles: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_users SET display_name=?,roles=?,updated_at=datetime('now') WHERE username=?",
(display_name, roles, username),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def update_password(username: str, password_hash: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_users SET password_hash=?,updated_at=datetime('now') WHERE username=?",
(password_hash, username),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def count_active_super_admins() -> int:
conn = db.get_conn()
rows = conn.execute(
"SELECT roles FROM admin_users WHERE status='active'"
).fetchall()
conn.close()
count = 0
for row in rows:
if "super_admin" in (row["roles"] or ""):
count += 1
return count
def set_user_status(username: str, status: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_users SET status=?,updated_at=datetime('now') WHERE username=?",
(status, username),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def revoke_refresh_tokens_for_user(username: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_refresh_tokens SET revoked_at=datetime('now') WHERE username=? AND revoked_at IS NULL",
(username,),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
def mark_login(username: str) -> None:
conn = db.get_conn()
conn.execute("UPDATE admin_users SET last_login_at=datetime('now') WHERE username=?", (username,))
conn.commit()
conn.close()
def save_refresh_token(token_id: str, username: str, token_hash: str, expires_at: str, user_agent: str, ip: str) -> None:
conn = db.get_conn()
conn.execute(
"""INSERT INTO admin_refresh_tokens(token_id,username,token_hash,expires_at,user_agent,ip)
VALUES(?,?,?,?,?,?)""",
(token_id, username, token_hash, expires_at, user_agent, ip),
)
conn.commit()
conn.close()
def get_refresh_token(token_id: str):
conn = db.get_conn()
row = conn.execute("SELECT * FROM admin_refresh_tokens WHERE token_id=?", (token_id,)).fetchone()
conn.close()
return row
def revoke_refresh_token(token_id: str) -> int:
conn = db.get_conn()
cur = conn.execute(
"UPDATE admin_refresh_tokens SET revoked_at=datetime('now') WHERE token_id=? AND revoked_at IS NULL",
(token_id,),
)
conn.commit()
count = cur.rowcount
conn.close()
return count
+5 -3
View File
@@ -17,6 +17,7 @@ def save_policy(
valid_until: str, valid_until: str,
min_supported_version: str, min_supported_version: str,
disabled_versions_json: str, disabled_versions_json: str,
git_tags_enabled: bool,
message: str, message: str,
) -> tuple[str, int | None]: ) -> tuple[str, int | None]:
conn = db.get_conn() conn = db.get_conn()
@@ -36,6 +37,7 @@ def save_policy(
valid_until, valid_until,
min_supported_version, min_supported_version,
disabled_versions_json, disabled_versions_json,
int(bool(git_tags_enabled)),
message, message,
app_id, app_id,
channel, channel,
@@ -43,13 +45,13 @@ def save_policy(
conn.execute( conn.execute(
""" """
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until, INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
min_supported_version,disabled_versions,message,app_id,channel) min_supported_version,disabled_versions,git_tags_enabled,message,app_id,channel)
VALUES(?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq, ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback, force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until, offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions, min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
message=excluded.message,updated_at=datetime('now') git_tags_enabled=excluded.git_tags_enabled,message=excluded.message,updated_at=datetime('now')
""", """,
values, values,
) )
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel
class AdminLoginRequest(BaseModel):
username: str
password: str
class RefreshTokenRequest(BaseModel):
refreshToken: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class AdminUserCreateRequest(BaseModel):
username: str
password: str
display_name: str = ""
roles: list[str] = ["auditor"]
class AdminUserUpdateRequest(BaseModel):
username: str
display_name: str = ""
roles: list[str]
class AdminUserStatusRequest(BaseModel):
username: str
status: str
class AdminUserResetPasswordRequest(BaseModel):
username: str
new_password: str
+1
View File
@@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel):
client_protocol: int = 3 client_protocol: int = 3
license_key: str = "" license_key: str = ""
api_base_url: str = "" api_base_url: str = ""
install_root: str = ""
main_executable: str = "" main_executable: str = ""
+5
View File
@@ -39,6 +39,11 @@ class DownloadReportRequest(BaseModel):
files: list[dict] files: list[dict]
class GitTagsRequest(BaseModel):
app_id: str
channel: str
class DeviceIssueRequest(BaseModel): class DeviceIssueRequest(BaseModel):
app_id: str app_id: str
channel: str channel: str
+1
View File
@@ -10,4 +10,5 @@ class PolicySaveRequest(BaseModel):
valid_until: str = "" valid_until: str = ""
min_supported_version: str = "" min_supported_version: str = ""
disabled_versions: list[str] = Field(default_factory=list) disabled_versions: list[str] = Field(default_factory=list)
git_tags_enabled: bool = False
message: str = "" message: str = ""
+1 -2
View File
@@ -57,7 +57,6 @@ def default_client_main_executable() -> str:
def default_client_config_values(request: Request) -> dict: def default_client_config_values(request: Request) -> dict:
return { return {
"api_base_url": public_api_base_url(request),
"client_token": VALID_CLIENT_TOKEN or "", "client_token": VALID_CLIENT_TOKEN or "",
"launch_token": os.getenv("CLIENT_LAUNCH_TOKEN", "SimCAE_Launch_Token_2026_ChangeMe_32Bytes"), "launch_token": os.getenv("CLIENT_LAUNCH_TOKEN", "SimCAE_Launch_Token_2026_ChangeMe_32Bytes"),
"request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"), "request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
@@ -107,7 +106,7 @@ def crash_report_test_values(api_base_url: str) -> dict:
"说明": "如果为空,查询/下载接口会使用 ADMIN_TOKEN。", "说明": "如果为空,查询/下载接口会使用 ADMIN_TOKEN。",
}, },
"ADMIN_TOKEN": { "ADMIN_TOKEN": {
"用途": "管理后台令牌;当 CRASH_ADMIN_TOKEN 为空时也用于查询/下载崩溃报告", "用途": "服务端兜底令牌;当 CRASH_ADMIN_TOKEN 为空时也用于查询/下载崩溃报告",
"token": admin_token, "token": admin_token,
}, },
}, },
+2
View File
@@ -13,6 +13,7 @@ def policy_row_to_dict(row) -> dict:
"valid_until": "2099-12-31T23:59:59Z", "valid_until": "2099-12-31T23:59:59Z",
"min_supported_version": "", "min_supported_version": "",
"disabled_versions": [], "disabled_versions": [],
"git_tags_enabled": False,
"message": "", "message": "",
} }
try: try:
@@ -27,6 +28,7 @@ def policy_row_to_dict(row) -> dict:
"valid_until": row["valid_until"], "valid_until": row["valid_until"],
"min_supported_version": row["min_supported_version"] or "", "min_supported_version": row["min_supported_version"] or "",
"disabled_versions": disabled if isinstance(disabled, list) else [], "disabled_versions": disabled if isinstance(disabled, list) else [],
"git_tags_enabled": bool(row["git_tags_enabled"]) if "git_tags_enabled" in row.keys() else False,
"message": row["message"] or "", "message": row["message"] or "",
} }
+129
View File
@@ -0,0 +1,129 @@
import json
import os
import ssl
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import Any
_CACHE: dict[str, Any] = {
"expires_at": 0.0,
"payload": None,
}
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _base_url() -> str:
return os.getenv("GITEA_BASE_URL", "https://git.alimzs.com:6443").rstrip("/")
def _token() -> str:
return os.getenv("GITEA_TOKEN", "").strip()
def _request_json(path: str) -> Any:
headers = {"Accept": "application/json"}
token = _token()
if token:
headers["Authorization"] = f"token {token}"
context = None
if not _env_bool("GITEA_VERIFY_SSL", False):
context = ssl._create_unverified_context()
req = urllib.request.Request(_base_url() + path, headers=headers)
timeout = float(os.getenv("GITEA_TIMEOUT_SEC", "12"))
with urllib.request.urlopen(req, timeout=timeout, context=context) as resp:
return json.loads(resp.read().decode("utf-8"))
def _paged(path: str) -> list[Any]:
result: list[Any] = []
page = 1
while True:
sep = "&" if "?" in path else "?"
data = _request_json(f"{path}{sep}limit=100&page={page}")
if isinstance(data, dict) and "data" in data:
data = data.get("data") or []
if not data:
break
if not isinstance(data, list):
raise RuntimeError("Gitea API returned an unexpected response.")
result.extend(data)
page += 1
return result
def _repo_list() -> list[dict[str, Any]]:
if _token():
repos = _paged("/api/v1/user/repos")
else:
repos = _paged("/api/v1/repos/search")
return [repo for repo in repos if isinstance(repo, dict) and repo.get("full_name")]
def _repo_tags(full_name: str) -> list[str]:
owner, name = full_name.split("/", 1)
owner_q = urllib.parse.quote(owner, safe="")
name_q = urllib.parse.quote(name, safe="")
tags = _paged(f"/api/v1/repos/{owner_q}/{name_q}/tags")
return [str(item.get("name")) for item in tags if isinstance(item, dict) and item.get("name")]
def format_tags_text(repositories: list[dict[str, Any]], generated_at: str) -> str:
lines = [
"# SimCAE Git Tag List",
f"# Generated at: {generated_at}",
f"# Source: {_base_url()}",
"",
]
for repo in repositories:
visibility = "private" if repo.get("private") else "public"
lines.append(f"[{repo['full_name']}]")
lines.append(f"default_branch={repo.get('default_branch') or ''}")
lines.append(f"visibility={visibility}")
tags = repo.get("tags") or []
if tags:
lines.extend(str(tag) for tag in tags)
else:
lines.append("(no tags)")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def load_git_tags(force_refresh: bool = False) -> dict[str, Any]:
ttl = max(0, int(os.getenv("GITEA_TAG_CACHE_TTL_SEC", "300") or "300"))
now = time.time()
if not force_refresh and _CACHE["payload"] and now < float(_CACHE["expires_at"]):
return _CACHE["payload"]
generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
repositories: list[dict[str, Any]] = []
for repo in _repo_list():
full_name = str(repo["full_name"])
tags = _repo_tags(full_name)
repositories.append(
{
"full_name": full_name,
"private": bool(repo.get("private")),
"default_branch": repo.get("default_branch") or "",
"html_url": repo.get("html_url") or "",
"tags": tags,
}
)
payload = {
"generated_at": generated_at,
"repo_count": len(repositories),
"tag_count": sum(len(repo["tags"]) for repo in repositories),
"repositories": repositories,
"tags_text": format_tags_text(repositories, generated_at),
}
_CACHE["payload"] = payload
_CACHE["expires_at"] = now + ttl
return payload
+2
View File
@@ -10,6 +10,8 @@ from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
def license_key_encryption_material() -> bytes: def license_key_encryption_material() -> bytes:
# 数据库只存 License Key 的密文和 hash。
# hash 用于客户端提交 License 时快速匹配;密文用于后台列表需要再次展示原始 Key 的场景。
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip() configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
if configured_secret: if configured_secret:
return configured_secret.encode("utf-8") return configured_secret.encode("utf-8")
+112 -12
View File
@@ -37,6 +37,41 @@ PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100))) 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: def normalize_relative_path(raw_path: str) -> str:
if not isinstance(raw_path, str): if not isinstance(raw_path, str):
raise HTTPException(status_code=400, detail="文件相对路径无效") raise HTTPException(status_code=400, detail="文件相对路径无效")
@@ -73,6 +108,8 @@ def should_skip_release_directory(part: str) -> bool:
def should_skip_release_file(relative_path: str) -> bool: def should_skip_release_file(relative_path: str) -> bool:
folded = relative_path.casefold() folded = relative_path.casefold()
# 发布包里不能带客户端本机运行态文件。
# 这些文件包含 License、设备身份、策略缓存等机器相关数据,应由客户端运行时生成或服务端重新签发。
runtime_protected_files = { runtime_protected_files = {
"client.ini", "client.ini",
"bootstrap.exe", "bootstrap.exe",
@@ -128,6 +165,8 @@ def supported_archive_kind(filename: str) -> str:
def ensure_inside_directory(root: Path, relative_path: str) -> Path: def ensure_inside_directory(root: Path, relative_path: str) -> Path:
# 解压压缩包时必须防止 Zip Slip / 路径穿越。
# 任何 ../ 或绝对路径都不能写出临时解压目录。
target = (root / relative_path).resolve() target = (root / relative_path).resolve()
root_resolved = root.resolve() root_resolved = root.resolve()
if target != root_resolved and root_resolved not in target.parents: if target != root_resolved and root_resolved not in target.parents:
@@ -256,7 +295,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") 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 = [] items = []
for path in extract_dir.rglob("*"): for path in extract_dir.rglob("*"):
if path.is_symlink() or not path.is_file(): if path.is_symlink() or not path.is_file():
@@ -265,13 +304,15 @@ def release_items_from_extracted_dir(extract_dir: Path):
if should_skip_release_path(rel_path): if should_skip_release_path(rel_path):
continue continue
items.append({"path": rel_path, "local_path": path}) 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):
# 有些压缩包会多包一层顶级目录,例如 SimCAE-1.0.0/bin/SimCAE.exe。
# 如果所有文件都在同一个顶级目录下,并且能找到主程序,就自动剥掉这一层,降低发布者操作成本。
if not items: if not items:
return 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] lower_paths = [item["path"].casefold() for item in items]
if required_main_key in lower_paths: if required_main_key in lower_paths:
return items return items
@@ -298,7 +339,7 @@ def normalize_release_item_roots(items: list[dict]):
return normalized return normalized
def validate_release_items(items: list[dict]): def validate_release_items(items: list[dict], required_main_executable: str):
if not items: if not items:
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包") raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
if len(items) > PUBLISH_MAX_FILES: if len(items) > PUBLISH_MAX_FILES:
@@ -322,15 +363,15 @@ def validate_release_items(items: list[dict]):
if not filtered: if not filtered:
raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件") 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: if required_main_key not in seen_paths:
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
if nested_main: if nested_main:
raise HTTPException( raise HTTPException(
status_code=400, 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) nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
if nested_main: if nested_main:
raise HTTPException( raise HTTPException(
@@ -382,6 +423,7 @@ async def close_upload_safely(upload):
def ensure_publish_storage_space(next_file_size: int): def ensure_publish_storage_space(next_file_size: int):
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
MINIO_DATA_DIR.mkdir(parents=True, exist_ok=True)
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
if available_bytes < required_bytes: if available_bytes < required_bytes:
raise HTTPException( raise HTTPException(
@@ -396,6 +438,8 @@ def ensure_publish_storage_space(next_file_size: int):
async def collect_publish_items(request: Request): async def collect_publish_items(request: Request):
# 发布入口支持两种方式:浏览器选择 Release 目录,或上传一个压缩发布包。
# 这里先做请求大小、临时目录空间和 MinIO 存储空间检查,避免大包把服务器拖死。
content_length_header = request.headers.get("content-length") content_length_header = request.headers.get("content-length")
try: try:
content_length = int(content_length_header or 0) content_length = int(content_length_header or 0)
@@ -461,6 +505,9 @@ async def collect_publish_items(request: Request):
app_id = form.get("app_id") app_id = form.get("app_id")
channel = form.get("channel") or "" channel = form.get("channel") or ""
version = form.get("version") 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: try:
client_protocol = int(form.get("client_protocol") or 2) client_protocol = int(form.get("client_protocol") or 2)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -526,8 +573,8 @@ async def collect_publish_items(request: Request):
extract_dir = archive_temp_dir / "extracted" extract_dir = archive_temp_dir / "extracted"
extract_dir.mkdir(parents=True, exist_ok=True) extract_dir.mkdir(parents=True, exist_ok=True)
extract_release_archive(archive_path, archive_name, extract_dir) extract_release_archive(archive_path, archive_name, extract_dir)
raw_items = release_items_from_extracted_dir(extract_dir) raw_items = release_items_from_extracted_dir(extract_dir, required_main_executable)
upload_items = validate_release_items(raw_items) upload_items = validate_release_items(raw_items, required_main_executable)
extracted_total = sum(item["local_path"].stat().st_size for item in upload_items) extracted_total = sum(item["local_path"].stat().st_size for item in upload_items)
check_extracted_publish_limits(extracted_total, len(upload_items)) check_extracted_publish_limits(extracted_total, len(upload_items))
else: else:
@@ -540,7 +587,7 @@ async def collect_publish_items(request: Request):
for index, file in enumerate(files): for index, file in enumerate(files):
raw_path = relative_paths[index] if relative_paths else file.filename raw_path = relative_paths[index] if relative_paths else file.filename
raw_items.append({"upload": file, "path": str(raw_path)}) 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: except Exception:
if archive_upload is not None: if archive_upload is not None:
await close_upload_safely(archive_upload) await close_upload_safely(archive_upload)
@@ -552,9 +599,12 @@ async def collect_publish_items(request: Request):
"channel": str(channel), "channel": str(channel),
"version": str(version), "version": str(version),
"client_protocol": client_protocol, "client_protocol": client_protocol,
"install_root": install_root,
"required_main_executable": required_main_executable,
"upload_items": upload_items, "upload_items": upload_items,
"archive_upload": archive_upload, "archive_upload": archive_upload,
"archive_temp_dir": archive_temp_dir, "archive_temp_dir": archive_temp_dir,
"job_temp_dir": None,
} }
@@ -570,11 +620,55 @@ def cleanup_path(path: Path | None):
print(f"清理临时文件失败: {path}: {err}") print(f"清理临时文件失败: {path}: {err}")
async def publish_version(request: Request): async def make_background_publish_payload(request: Request):
# 后台发布任务不能继续持有浏览器上传流,所以目录模式要先复制到服务端临时目录。
# 压缩包模式在 collect_publish_items 中已经解压为本地文件,只需要保留解压目录到任务结束。
payload = await collect_publish_items(request) payload = await collect_publish_items(request)
job_temp_dir = Path(tempfile.mkdtemp(prefix="publish_job_", dir=UPLOAD_SPOOL_DIR))
converted_items = []
try:
for item in payload["upload_items"]:
if "upload" not in item:
converted_items.append(item)
continue
upload = item["upload"]
target = ensure_inside_directory(job_temp_dir, item["path"])
target.parent.mkdir(parents=True, exist_ok=True)
try:
upload.file.seek(0)
with target.open("wb") as output:
shutil.copyfileobj(upload.file, output, length=1024 * 1024)
except OSError as err:
raise HTTPException(status_code=500, detail={"error": "publish_job_spool_failed", "msg": str(err)})
finally:
await close_upload_safely(upload)
converted_items.append({"path": item["path"], "local_path": target})
payload["upload_items"] = converted_items
payload["job_temp_dir"] = job_temp_dir
payload["archive_upload"] = None
return payload
except Exception:
for item in payload.get("upload_items", []):
upload = item.get("upload") if isinstance(item, dict) else None
if upload is not None:
await close_upload_safely(upload)
archive_upload = payload.get("archive_upload")
if archive_upload is not None:
await close_upload_safely(archive_upload)
cleanup_path(payload.get("archive_temp_dir"))
cleanup_path(job_temp_dir)
raise
async def publish_payload(payload: dict):
# 完整发布事务:
# 1. 使用已收集的发布文件;2. 创建版本记录;3. 上传文件到 MinIO4. 写入文件 hash/size。
# 任何一步失败都会回滚数据库,并删除已经上传的对象前缀,避免出现“半个版本”。
upload_items = payload["upload_items"] upload_items = payload["upload_items"]
archive_upload = payload["archive_upload"] archive_upload = payload["archive_upload"]
archive_temp_dir = payload["archive_temp_dir"] archive_temp_dir = payload["archive_temp_dir"]
job_temp_dir = payload.get("job_temp_dir")
app_id = payload["app_id"] app_id = payload["app_id"]
channel = payload["channel"] channel = payload["channel"]
version = payload["version"] version = payload["version"]
@@ -649,3 +743,9 @@ async def publish_version(request: Request):
if archive_upload is not None: if archive_upload is not None:
await close_upload_safely(archive_upload) await close_upload_safely(archive_upload)
cleanup_path(archive_temp_dir) cleanup_path(archive_temp_dir)
cleanup_path(job_temp_dir)
async def publish_version(request: Request):
payload = await collect_publish_items(request)
return await publish_payload(payload)
+4
View File
@@ -21,11 +21,15 @@ def load_manifest_private_key():
def canonical_manifest_bytes(manifest_obj: dict) -> bytes: def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
# 签名之前必须先规范化 JSON:固定字段顺序、去掉空格、排除 signature 字段。
# 否则同一份 Manifest 在不同环境下序列化结果不同,会导致客户端验签失败。
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"} copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def sign_manifest(manifest_obj: dict) -> str: def sign_manifest(manifest_obj: dict) -> str:
# 服务端只保存私钥,客户端只分发公钥。
# 客户端能验证 Manifest 确实由服务端签发,但无法伪造新的签名。
json_text = canonical_manifest_bytes(manifest_obj) json_text = canonical_manifest_bytes(manifest_obj)
private_key = load_manifest_private_key() private_key = load_manifest_private_key()
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256()) signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
+2 -1
View File
@@ -34,6 +34,7 @@ services:
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
api: api:
# 离线部署包使用已经 docker load 导入的镜像,不在目标服务器重新 build。
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0} image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
restart: unless-stopped restart: unless-stopped
user: "${APP_UID:-1000}:${APP_GID:-1000}" user: "${APP_UID:-1000}:${APP_GID:-1000}"
@@ -45,7 +46,6 @@ services:
ENV_FILE_PATH: /app/.env ENV_FILE_PATH: /app/.env
DB_FILE: /data/mini.db DB_FILE: /data/mini.db
SQL_FILE: /app/tables.sql SQL_FILE: /app/tables.sql
ADMIN_HTML_PATH: /app/legacy/admin.html
LOCAL_UPLOAD_ROOT: /data/uploads LOCAL_UPLOAD_ROOT: /data/uploads
UPLOAD_SPOOL_DIR: /data/upload_spool UPLOAD_SPOOL_DIR: /data/upload_spool
MINIO_DATA_DIR: /minio_data MINIO_DATA_DIR: /minio_data
@@ -58,6 +58,7 @@ services:
ports: ports:
- "${SERVER_PORT:-8000}:8000" - "${SERVER_PORT:-8000}:8000"
volumes: volumes:
# runtime 保存 SQLite、上传缓存和崩溃报告等运行数据;keys 只读挂载,避免私钥被容器误改。
- ./.env:/app/.env - ./.env:/app/.env
- ./runtime:/data - ./runtime:/data
- ./minio_data:/minio_data:ro - ./minio_data:/minio_data:ro
+4 -3
View File
@@ -34,9 +34,10 @@ services:
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
api: api:
# 开发/源码部署使用 build,从当前源码构建镜像;正式离线交付通常使用 docker-compose.image.yml 生成的包。
build: build:
context: .. context: .
dockerfile: server/Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
user: "${APP_UID:-1000}:${APP_GID:-1000}" user: "${APP_UID:-1000}:${APP_GID:-1000}"
env_file: env_file:
@@ -47,7 +48,6 @@ services:
ENV_FILE_PATH: /app/.env ENV_FILE_PATH: /app/.env
DB_FILE: /data/mini.db DB_FILE: /data/mini.db
SQL_FILE: /app/tables.sql SQL_FILE: /app/tables.sql
ADMIN_HTML_PATH: /app/legacy/admin.html
LOCAL_UPLOAD_ROOT: /data/uploads LOCAL_UPLOAD_ROOT: /data/uploads
UPLOAD_SPOOL_DIR: /data/upload_spool UPLOAD_SPOOL_DIR: /data/upload_spool
MINIO_DATA_DIR: /minio_data MINIO_DATA_DIR: /minio_data
@@ -60,6 +60,7 @@ services:
ports: ports:
- "${SERVER_PORT:-8000}:8000" - "${SERVER_PORT:-8000}:8000"
volumes: volumes:
# 数据卷和密钥挂载在容器外,重建镜像或升级容器时不会丢失历史版本和授权数据。
- ./.env:/app/.env - ./.env:/app/.env
- ./runtime:/data - ./runtime:/data
- ./minio_data:/minio_data:ro - ./minio_data:/minio_data:ro
-13
View File
@@ -1,13 +0,0 @@
历史兼容文件
============
本目录保存旧版单文件管理后台。
当前主后台是 admin-ui/ 下的 Vue3 + Element Plus 项目,Docker 构建时会编译 admin-ui/dist 并作为默认首页提供。
legacy/admin.html 只作为兼容 fallback
1. 如果 admin-ui/dist 不存在,访问 / 会回退到 legacy/admin.html。
2. 访问 /legacy-admin.html 会直接打开 legacy/admin.html。
正常维护新后台时,请优先修改 admin-ui/。
-2672
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -18,6 +18,7 @@ import minio_tool
from app.api.router import include_api_routes from app.api.router import include_api_routes
from app.core.audit import admin_audit_middleware from app.core.audit import admin_audit_middleware
from app.core.config import configured_path, env_bool, settings 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.crash_api_service import ensure_storage_dirs
from app.services.device_credential_service import verify_device_credential from app.services.device_credential_service import verify_device_credential
from app.services.publish_service import UPLOAD_SPOOL_DIR from app.services.publish_service import UPLOAD_SPOOL_DIR
@@ -54,6 +55,16 @@ async def lifespan(app: FastAPI):
license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()} license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()}
if "license_key_cipher" not in license_columns: if "license_key_cipher" not in license_columns:
cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''") cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''")
policy_columns = {row[1] for row in cur.execute("PRAGMA table_info(version_policies)").fetchall()}
if "git_tags_enabled" not in policy_columns:
cur.execute("ALTER TABLE version_policies ADD COLUMN git_tags_enabled INTEGER NOT NULL DEFAULT 0")
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(): 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(): if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone():
cur.executemany( cur.executemany(
@@ -72,6 +83,7 @@ async def lifespan(app: FastAPI):
print("===== 数据库初始化完成 =====") print("===== 数据库初始化完成 =====")
ensure_storage_dirs() ensure_storage_dirs()
ensure_default_admin_user()
start_minio_if_needed() start_minio_if_needed()
yield yield
@@ -175,6 +187,8 @@ async def auth_middleware(request: Request, call_next):
"/platform-config.json", "/platform-config.json",
"/logo.svg", "/logo.svg",
"/health", "/health",
"/login",
"/refresh-token",
"/api/v1/health", "/api/v1/health",
"/api/v1/crash-reports", "/api/v1/crash-reports",
"/api/v1/symbols", "/api/v1/symbols",
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8,<9
+2
View File
@@ -1,6 +1,8 @@
cryptography==49.0.0 cryptography==49.0.0
fastapi==0.138.0 fastapi==0.138.0
minio==7.2.20 minio==7.2.20
argon2-cffi==25.1.0
PyJWT==2.10.1
python-dotenv==1.2.2 python-dotenv==1.2.2
python-multipart==0.0.32 python-multipart==0.0.32
uvicorn==0.49.0 uvicorn==0.49.0
+27 -11
View File
@@ -110,7 +110,7 @@ cp "$PROJECT_ROOT/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key
cp "$PROJECT_ROOT/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem" cp "$PROJECT_ROOT/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem"
cat > "$PACKAGE_DIR/README.md" <<'EOF_README' cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
# SimCAE 服务端 Docker 部署包说明 # SimCAE 服务端 Docker 打包和部署指南
本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。 本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。
@@ -118,6 +118,17 @@ cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。 如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。
## 先看这里:你要做哪件事
| 你的目标 | 直接看哪一节 |
| --- | --- |
| 从源码重新生成离线部署包 | 一、维护者:生成 Docker 离线部署包 |
| 了解交付包里有哪些文件 | 二、这个包里面有什么 |
| 在服务器上部署并启动 | 三、使用者:最小部署流程 |
| 修改 `.env` 配置 | 四、.env 配置怎么填 |
| 发布 Windows/Linux 客户端版本 | 六、发布新版本 |
| 查看日志、重启、备份、升级 | 七、常用命令 |
## 一、维护者:生成 Docker 离线部署包 ## 一、维护者:生成 Docker 离线部署包
在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。 在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。
@@ -251,7 +262,7 @@ bash ./load-images.sh
nano .env nano .env
``` ```
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: 最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成客户端能访问到的服务器地址:
```text ```text
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
@@ -301,11 +312,14 @@ MinIO 控制台: http://你的服务器IP:9001/
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | | `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | | `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 | | `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成 qrc 服务端配置 `server_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`Linux 示例:`bin/SimCAE`。 | | `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`Linux 示例:`bin/SimCAE`。 |
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | | `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 必填 | 可以 | 管理后台初始用户名和密码。首次启动且数据库里没有管理员用户时,会自动创建这个账号。网页登录时输入这组账号密码。 |
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | | `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
| `GITEA_BASE_URL` / `GITEA_TOKEN` | 可不填 | 可以 | 策略页勾选“允许 Launcher 生成 Git 标签清单”时使用。`GITEA_TOKEN` 只保存在服务端,用来访问 Gitea 仓库和 tags,不会返回给客户端。只查公开仓库时可不填 Token;要查私有仓库必须填写。 |
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | | `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | | `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
@@ -362,15 +376,15 @@ MinIO 控制台: http://你的服务器IP:9001/
3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成” 3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
4. 打开“客户端配置生成” 4. 打开“客户端配置生成”
5. 点击“生成配套配置” 5. 点击“生成配套配置”
6. 点击“复制配置” 6. 点击“复制配置”,粘贴到客户端 `bin/config/app_config.json`
7. 粘贴到客户端 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 ```json
{ {
"api_base_url": "http://你的服务器IP:8000",
"client_token": "和服务端 CLIENT_API_TOKEN 一样" "client_token": "和服务端 CLIENT_API_TOKEN 一样"
} }
``` ```
@@ -387,7 +401,7 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
"client_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` 或注册表。
## 六、发布新版本 ## 六、发布新版本
@@ -398,6 +412,8 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar 2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
``` ```
点击“发布版本”后,浏览器会先把文件上传到服务端。上传完成后,服务端会创建后台发布任务,继续执行解压、Manifest 校验、数据库写入和 MinIO 上传;管理页面会轮询并显示任务状态。这样可以减少大版本发布时长时间占用同一个 HTTP 请求的问题。
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是: 压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
```text ```text
@@ -531,13 +547,13 @@ CLIENT_API_TOKEN=...
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
``` ```
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。 这个地址必须从客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
## 十、安全提醒 ## 十、安全提醒
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 `keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 默认用户名、默认密码、默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
EOF_README EOF_README
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME' python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
+30 -1
View File
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS version_policies (
valid_until TEXT NOT NULL, valid_until TEXT NOT NULL,
min_supported_version TEXT NOT NULL DEFAULT '', min_supported_version TEXT NOT NULL DEFAULT '',
disabled_versions TEXT NOT NULL DEFAULT '[]', disabled_versions TEXT NOT NULL DEFAULT '[]',
git_tags_enabled INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
@@ -111,13 +112,41 @@ CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,c
-- 11. 管理后台写操作审计 -- 11. 管理后台写操作审计
CREATE TABLE IF NOT EXISTS admin_audit_logs ( 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, 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 '', status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at); 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 崩溃报告原始数据索引 -- 12. SimCAE 崩溃报告原始数据索引
CREATE TABLE IF NOT EXISTS crash_reports ( CREATE TABLE IF NOT EXISTS crash_reports (
+86
View File
@@ -0,0 +1,86 @@
import importlib
import os
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def purge_server_modules():
for name in list(sys.modules):
if name == "main" or name == "db" or name == "minio_tool" or name.startswith("app."):
sys.modules.pop(name, None)
def write_test_private_key(path: Path):
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
class TestPasswordHasher:
def hash(self, password: str) -> str:
return "test-hash:" + password
def verify(self, password_hash: str, password: str) -> bool:
return password_hash == self.hash(password)
@pytest.fixture()
def server_module(tmp_path, monkeypatch):
key_path = tmp_path / "keys" / "manifest_private_key.pem"
write_test_private_key(key_path)
env = {
"ENV_FILE_PATH": str(tmp_path / ".env"),
"DB_FILE": str(tmp_path / "mini.db"),
"SQL_FILE": str(PROJECT_ROOT / "tables.sql"),
"ADMIN_TOKEN": "AdminTokenForTests2026",
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "AdminPass2026",
"ADMIN_JWT_SECRET": "AdminJwtSecretForTests2026",
"CLIENT_API_TOKEN": "ClientTokenForTests2026",
"CRASH_REPORT_TOKEN": "CrashReportTokenForTests2026",
"CRASH_SYMBOL_TOKEN": "CrashSymbolTokenForTests2026",
"CRASH_ADMIN_TOKEN": "CrashAdminTokenForTests2026",
"LICENSE_KEY_ENCRYPTION_SECRET": "LicenseEncryptionSecretForTests2026",
"MANIFEST_PRIVATE_KEY_PATH": str(key_path),
"MINIO_AUTO_START": "false",
"MINIO_ENDPOINT": "127.0.0.1:1",
"MINIO_ACCESS_KEY": "minio_test",
"MINIO_SECRET_KEY": "minio_test_secret",
"MINIO_BUCKET": "updates",
"MINIO_PUBLIC_ENDPOINT": "",
"MINIO_CONNECT_TIMEOUT_SEC": "0.1",
"MINIO_READ_TIMEOUT_SEC": "0.1",
"MINIO_RETRY_TOTAL": "0",
"LOCAL_UPLOAD_ROOT": str(tmp_path / "local_uploads"),
"MINIO_DATA_DIR": str(tmp_path / "minio_data"),
"UPLOAD_SPOOL_DIR": str(tmp_path / "upload_spool"),
"CRASH_STORAGE_ROOT": str(tmp_path / "crash_storage"),
"TARGET_PLATFORM": "windows",
"TARGET_ARCH": "x64",
"RELEASE_MAIN_EXECUTABLE": "bin/SimCAE.exe",
}
for key, value in env.items():
monkeypatch.setenv(key, value)
monkeypatch.chdir(PROJECT_ROOT)
purge_server_modules()
main = importlib.import_module("main")
security = importlib.import_module("app.core.security")
security.PASSWORD_HASHER = TestPasswordHasher()
yield main
purge_server_modules()
+192
View File
@@ -0,0 +1,192 @@
import base64
import asyncio
import json
from types import SimpleNamespace
from fastapi import HTTPException
def fake_request(**kwargs):
return SimpleNamespace(
headers=kwargs.get("headers", {}),
client=SimpleNamespace(host=kwargs.get("host", "127.0.0.1")),
state=SimpleNamespace(**kwargs.get("state", {})),
)
def add_app(app_id="testapp", app_name="Test App"):
from app.api.routes import admin_app_channel
from app.schemas.app_channel import AppCreateRequest
response = admin_app_channel.admin_add_app(AppCreateRequest(app_id=app_id, app_name=app_name))
assert response["msg"] == "应用创建成功"
def create_license(app_id="testapp", channel="stable"):
from app.api.routes import admin_license
from app.schemas.license import LicenseCreateRequest
response = admin_license.admin_license_create(
LicenseCreateRequest(
app_id=app_id,
channel=channel,
customer_name="自动化测试客户",
max_devices=1,
valid_until="2099-12-31T23:59:59Z",
)
)
return response["license_key"]
def encode_device_credential(issue_response: dict) -> str:
wrapper = {
"identity_text": issue_response["identity_text"],
"signature": issue_response["signature"],
}
return base64.b64encode(json.dumps(wrapper, separators=(",", ":")).encode()).decode()
async def run_admin_login_license_and_device_issue(server_module):
async with server_module.lifespan(server_module.app):
from app.api.routes import admin_auth, admin_license, client_update
from app.schemas.admin_auth import AdminLoginRequest
from app.schemas.client_update import CheckUpdateRequest, DeviceIssueRequest
login = admin_auth.login(
AdminLoginRequest(username="admin", password="AdminPass2026"),
fake_request(headers={"user-agent": "pytest"}),
)
assert login["success"] is True
assert login["data"]["accessToken"]
add_app()
license_key = create_license()
issue_data = await client_update.issue_device(
fake_request(),
DeviceIssueRequest(
app_id="testapp",
channel="stable",
license_key=license_key,
installation_id="install-test-000001",
machine_hash="ab" * 32,
),
)
assert issue_data["identity"]["app_id"] == "testapp"
assert issue_data["identity"]["channel"] == "stable"
credential = encode_device_credential(issue_data)
assert credential
check_data = await client_update.check_update(
fake_request(state={"device_identity": issue_data["identity"]}),
CheckUpdateRequest(
app_id="testapp",
channel="stable",
current_version="1.0.0",
client_protocol=3,
),
)
assert check_data["allow_run"] is True
assert check_data["release_available"] is False
licenses = admin_license.admin_license_list(app_id="testapp")
assert licenses["list"][0]["used_devices"] == 1
def test_admin_login_license_and_device_issue(server_module):
asyncio.run(run_admin_login_license_and_device_issue(server_module))
async def run_git_tags_policy_gate(server_module, monkeypatch):
async with server_module.lifespan(server_module.app):
from app.api.routes import admin_policy, client_update, git_tags
from app.schemas.client_update import DeviceIssueRequest, GitTagsRequest
from app.schemas.policy import PolicySaveRequest
add_app("gitapp", "Git App")
license_key = create_license("gitapp", "stable")
issue_data = await client_update.issue_device(
fake_request(),
DeviceIssueRequest(
app_id="gitapp",
channel="stable",
license_key=license_key,
installation_id="install-git-tags-000001",
machine_hash="cd" * 32,
),
)
request = fake_request(state={"device_identity": issue_data["identity"]})
body = GitTagsRequest(app_id="gitapp", channel="stable")
try:
git_tags.client_git_tags(request, body)
assert False, "Git tags should be blocked before the policy is enabled"
except HTTPException as exc:
assert exc.status_code == 403
assert exc.detail["error"] == "git_tags_disabled"
admin_policy.admin_save_policy(
PolicySaveRequest(
app_id="gitapp",
channel="stable",
valid_until="2099-12-31T23:59:59Z",
git_tags_enabled=True,
)
)
monkeypatch.setattr(
git_tags,
"load_git_tags",
lambda force_refresh=False: {
"generated_at": "2026-07-20T00:00:00Z",
"repo_count": 1,
"tag_count": 2,
"tags_text": "[owner/repo]\nv1.0.0\nv1.0.1\n",
},
)
result = git_tags.client_git_tags(request, body)
assert result["repo_count"] == 1
assert result["tag_count"] == 2
assert "v1.0.1" in result["tags_text"]
def test_git_tags_policy_gate(server_module, monkeypatch):
asyncio.run(run_git_tags_policy_gate(server_module, monkeypatch))
async def run_publish_payload_creates_version(server_module, tmp_path):
async with server_module.lifespan(server_module.app):
from app.api.routes import admin_version
from app.services import publish_service
add_app("publishapp", "Publish App")
release_dir = tmp_path / "release"
bin_dir = release_dir / "bin"
bin_dir.mkdir(parents=True)
exe_path = bin_dir / "SimCAE.exe"
readme_path = release_dir / "README.txt"
exe_path.write_bytes(b"fake exe")
readme_path.write_text("hello", encoding="utf-8")
result = await publish_service.publish_payload(
{
"upload_items": [
{"path": "bin/SimCAE.exe", "local_path": exe_path},
{"path": "README.txt", "local_path": readme_path},
],
"archive_upload": None,
"archive_temp_dir": None,
"app_id": "publishapp",
"channel": "stable",
"version": "1.0.1",
"client_protocol": 3,
}
)
assert result["file_count"] == 2
versions = admin_version.admin_get_version_list("publishapp")
assert any(item["version"] == "1.0.1" for item in versions["list"])
def test_publish_payload_creates_version(server_module, tmp_path):
asyncio.run(run_publish_payload_creates_version(server_module, tmp_path))
+113
View File
@@ -0,0 +1,113 @@
import hashlib
import io
import json
from types import SimpleNamespace
import uuid
def crash_metadata(client_report_id: str, minidump: bytes):
return {
"schemaVersion": 1,
"clientReportId": client_report_id,
"crashTimeUtc": "2026-07-16T00:00:00Z",
"product": "SIMCAE",
"appVersion": "1.0.0",
"gitCommit": "test-commit",
"buildType": "Release",
"channel": "stable",
"platform": {"os": "Windows", "arch": "x64"},
"crash": {"exceptionCode": "0xC0000005"},
"userConsent": {"upload": True},
"files": [
{
"kind": "minidump",
"name": "crash.dmp",
"sha256": hashlib.sha256(minidump).hexdigest(),
}
],
}
class FakeHeaders(dict):
def __init__(self, values: dict[str, str]):
super().__init__((key.lower(), value) for key, value in values.items())
def get(self, key: str, default=None):
return super().get(key.lower(), default)
class FakeUpload:
def __init__(self, filename: str, data: bytes):
self.filename = filename
self._stream = io.BytesIO(data)
async def read(self, size: int = -1) -> bytes:
return self._stream.read(size)
class FakeMultipartRequest:
def __init__(self, headers: dict[str, str], form: dict, host: str = "127.0.0.1"):
self.headers = FakeHeaders(headers)
self._form = form
self.client = SimpleNamespace(host=host)
async def form(self):
return self._form
async def run_crash_report_upload_detail_and_duplicate(server_module):
async with server_module.lifespan(server_module.app):
from app.services import crash_api_service
minidump = b"fake minidump bytes"
client_report_id = str(uuid.uuid4())
metadata = crash_metadata(client_report_id, minidump)
metadata_bytes = json.dumps(metadata).encode()
headers = {
"Authorization": "Bearer CrashReportTokenForTests2026",
"Idempotency-Key": client_report_id,
"Content-Type": "multipart/form-data; boundary=test",
"Content-Length": str(len(metadata_bytes) + len(minidump)),
"X-SimCAE-Client": "SIMCAE",
"X-SimCAE-Version": "1.0.0",
}
upload = await crash_api_service.create_crash_report(
FakeMultipartRequest(
headers,
{
"metadata": FakeUpload("metadata.json", metadata_bytes),
"minidump": FakeUpload("crash.dmp", minidump),
},
)
)
assert upload.status_code == 201
upload_body = json.loads(upload.body)
report_id = upload_body["reportId"]
duplicate = await crash_api_service.create_crash_report(
FakeMultipartRequest(
headers,
{
"metadata": FakeUpload("metadata.json", metadata_bytes),
"minidump": FakeUpload("crash.dmp", minidump),
},
)
)
assert duplicate.status_code == 200
assert json.loads(duplicate.body)["duplicate"] is True
detail = crash_api_service.get_crash_report_detail(
report_id,
FakeMultipartRequest(
{"Authorization": "Bearer CrashAdminTokenForTests2026"},
{},
),
)
assert detail["clientReportId"] == client_report_id
def test_crash_report_upload_detail_and_duplicate(server_module):
import asyncio
asyncio.run(run_crash_report_upload_detail_and_duplicate(server_module))