Compare commits
9 Commits
95dd32c75a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 801622904c | |||
| bc44ce8053 | |||
| 5d7aa560e5 | |||
| 0b71e8d024 | |||
| 4df439b5ce | |||
| 0548692db0 | |||
| d7c7eeefc7 | |||
| 1b4293e57c | |||
| 6df8e3ad31 |
+35
-6
@@ -5,7 +5,7 @@ SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0
|
||||
SERVER_PORT=8000
|
||||
|
||||
# 如果后台经过域名、反向代理或端口映射访问,可以在这里固定客户端 API 地址。
|
||||
# 留空时,管理页会按当前访问后台的地址自动生成 api_base_url。
|
||||
# 留空时,管理页会按当前访问后台的地址自动生成 qrc 服务端配置里的 api_base_url。
|
||||
PUBLIC_API_BASE_URL=
|
||||
|
||||
# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。
|
||||
@@ -18,11 +18,14 @@ SERVICE_TITLE=SimCAE Update Service
|
||||
# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
|
||||
# 发布包目标平台。当前客户端是 Windows x64,通常不用改。
|
||||
# 发布包目标平台。Windows 客户端填 windows;Linux 客户端填 linux。
|
||||
# 改成 linux 后,管理页生成 app_config.json 时会默认使用 Launcher/Updater/Bootstrap 这类无 .exe 文件名。
|
||||
TARGET_PLATFORM=windows
|
||||
TARGET_ARCH=x64
|
||||
|
||||
# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。
|
||||
# 业务主程序相对发布根目录的路径。
|
||||
# Windows 示例:bin/SimCAE.exe
|
||||
# Linux 示例:bin/SimCAE
|
||||
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||
|
||||
# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。
|
||||
@@ -41,15 +44,41 @@ UPLOAD_SPACE_RESERVE_MB=256
|
||||
# 管理页“客户端配置生成”会自动把这个值写入 app_config.json 的 client_token。
|
||||
CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||
|
||||
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在管理员令牌输入这个值,正式部署建议修改。
|
||||
# 服务端兜底令牌。管理后台使用用户名/密码登录 + JWT,不再用它直接登录。
|
||||
# 这个值仍用于 ADMIN_JWT_SECRET 未设置时的默认签名密钥,以及 CRASH_ADMIN_TOKEN 为空时的崩溃报告管理兜底令牌。
|
||||
ADMIN_TOKEN=SimCAEAdminToken2026
|
||||
|
||||
# 管理后台初始用户。首次启动且数据库里没有管理员用户时,会自动创建这个账号。
|
||||
# 登录后台时使用 ADMIN_USERNAME / ADMIN_PASSWORD。正式部署建议修改。
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=SimCAEAdminToken2026
|
||||
ADMIN_DISPLAY_NAME=超级管理员
|
||||
|
||||
# 管理后台 JWT 配置。正式部署建议改成随机长字符串,并长期保持不变。
|
||||
ADMIN_JWT_SECRET=SimCAE_Admin_JWT_2026_ChangeMe
|
||||
ADMIN_ACCESS_TOKEN_EXPIRE_MIN=120
|
||||
ADMIN_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# License Key 加密保存密钥。后台授权列表需要用它解密显示 License Key。
|
||||
# 已给默认值,可直接试跑;正式部署建议修改,并且部署后长期保持不变。
|
||||
# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。
|
||||
LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe
|
||||
|
||||
# 管理页生成 app_config.json 时使用的客户端默认值。普通 SimCAE 部署通常不用改。
|
||||
# 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 部署通常不用改。
|
||||
# Linux 部署可以改成:
|
||||
# CLIENT_MAIN_EXECUTABLE=SimCAE
|
||||
# CLIENT_LAUNCHER_EXECUTABLE=Launcher
|
||||
# CLIENT_UPDATER_EXECUTABLE=Updater
|
||||
# CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap
|
||||
# 如果 TARGET_PLATFORM=linux 但这里仍写了 .exe,后端生成配置时会自动去掉 .exe。
|
||||
CLIENT_LAUNCH_TOKEN=SimCAE_Launch_Token_2026_ChangeMe_32Bytes
|
||||
CLIENT_REQUEST_TIMEOUT_MS=5000
|
||||
CLIENT_TEMP_FOLDER=update_temp
|
||||
@@ -86,7 +115,7 @@ MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe
|
||||
# MinIO 存储桶名称。通常不用改。
|
||||
MINIO_BUCKET=updates
|
||||
|
||||
# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。
|
||||
# 客户端下载升级文件时访问的 MinIO 地址。必须改成客户端能访问到的服务器地址。
|
||||
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
|
||||
|
||||
# MinIO 预签名下载链接有效期,单位分钟。通常不用改。
|
||||
|
||||
+6
-2
@@ -1,3 +1,5 @@
|
||||
# 第一阶段:构建 Vue 管理后台。
|
||||
# 最终镜像只需要 admin-ui/dist,不需要 Node、源码依赖和 node_modules。
|
||||
FROM node:22-alpine AS admin_ui_builder
|
||||
|
||||
WORKDIR /ui
|
||||
@@ -9,6 +11,8 @@ COPY admin-ui ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# 第二阶段:运行 FastAPI 服务端。
|
||||
# 镜像里只放后端代码、Python 依赖和已经构建好的管理后台静态文件。
|
||||
FROM python:3.12-slim
|
||||
|
||||
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 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
|
||||
|
||||
# 运行数据不进镜像,启动时通过 docker-compose 挂载到 /data、/minio_data、/run/secrets/update-keys。
|
||||
# 这样升级文件、SQLite 数据库和私钥可以独立备份,也不会跟镜像版本绑死。
|
||||
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
||||
&& 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 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 包的交付流程。
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
|
||||
如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。
|
||||
|
||||
## 先看这里:你要做哪件事
|
||||
|
||||
| 你的目标 | 直接看哪一节 |
|
||||
| --- | --- |
|
||||
| 从源码重新生成离线部署包 | 一、维护者:生成 Docker 离线部署包 |
|
||||
| 了解交付包里有哪些文件 | 二、这个包里面有什么 |
|
||||
| 在服务器上部署并启动 | 三、使用者:最小部署流程 |
|
||||
| 修改 `.env` 配置 | 四、.env 配置怎么填 |
|
||||
| 发布 Windows/Linux 客户端版本 | 六、发布新版本 |
|
||||
| 查看日志、重启、备份、升级 | 七、常用命令 |
|
||||
|
||||
## 一、维护者:生成 Docker 离线部署包
|
||||
|
||||
在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。
|
||||
@@ -139,7 +150,7 @@ bash ./load-images.sh
|
||||
nano .env
|
||||
```
|
||||
|
||||
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址:
|
||||
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成客户端能访问到的服务器地址:
|
||||
|
||||
```text
|
||||
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||
@@ -187,12 +198,14 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
|
||||
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
|
||||
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
|
||||
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
|
||||
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
|
||||
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 |
|
||||
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成 qrc 服务端配置 `server_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
|
||||
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`;Linux 示例:`bin/SimCAE`。 |
|
||||
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
|
||||
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 必填 | 可以 | 管理后台初始用户名和密码。首次启动且数据库里没有管理员用户时,会自动创建这个账号。网页登录时输入这组账号密码。 |
|
||||
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
|
||||
| `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
|
||||
| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
|
||||
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
|
||||
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
|
||||
@@ -205,7 +218,7 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
|
||||
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
|
||||
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 |
|
||||
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 |
|
||||
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | Windows x64 填 `windows` / `x64`;Linux x64 填 `linux` / `x64`。 |
|
||||
| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
|
||||
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
|
||||
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
|
||||
@@ -213,6 +226,18 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
|
||||
| `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,6 +265,8 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 |
|
||||
| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 |
|
||||
|
||||
崩溃报告上传、查询、下载、符号包上传的 curl 联调步骤见 `Docs/02-崩溃报告接口联调指南.md`。示例里的 `dev-token` 不是固定值,要按接口用途替换为 `CRASH_REPORT_TOKEN`、`CRASH_SYMBOL_TOKEN` 或 `CRASH_ADMIN_TOKEN` / `ADMIN_TOKEN`。
|
||||
|
||||
## 五、客户端配置要同步哪些值
|
||||
|
||||
推荐直接使用后台页面生成:
|
||||
@@ -250,15 +277,15 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
|
||||
4. 打开“客户端配置生成”
|
||||
5. 点击“生成配套配置”
|
||||
6. 点击“复制配置”
|
||||
7. 粘贴到客户端 bin/config/app_config.json
|
||||
6. 点击“复制配置”,粘贴到客户端 `bin/config/app_config.json`
|
||||
7. 点击“复制 qrc 配置”,粘贴到客户端源码 `config/server_config.json`
|
||||
8. 重新编译 Launcher / Updater / Bootstrap,让 `api_base_url` 通过 qrc 编进程序
|
||||
```
|
||||
|
||||
客户端 `config/app_config.json` 至少要和服务端保持这两个值一致:
|
||||
客户端 `config/app_config.json` 至少要和服务端保持这个值一致:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_base_url": "http://你的服务器IP:8000",
|
||||
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
|
||||
}
|
||||
```
|
||||
@@ -275,23 +302,36 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||
"client_token": "SimCAEClientToken2026"
|
||||
```
|
||||
|
||||
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
|
||||
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。它现在位于客户端源码 `config/server_config.json`,并通过 qrc 编译进程序,不再写入客户端 `app_config.json` 或注册表。
|
||||
|
||||
## 六、发布新版本
|
||||
|
||||
进入后台后,“发布新版本”支持两种方式:
|
||||
|
||||
```text
|
||||
1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe
|
||||
1. 选择软件发布根目录,例如 SimCAE 目录。Windows 目录内应包含 bin/SimCAE.exe;Linux 目录内应包含 bin/SimCAE
|
||||
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
||||
```
|
||||
|
||||
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是:
|
||||
点击“发布版本”后,浏览器会先把文件上传到服务端。上传完成后,服务端会创建后台发布任务,继续执行解压、Manifest 校验、数据库写入和 MinIO 上传;管理页面会轮询并显示任务状态。这样可以减少大版本发布时长时间占用同一个 HTTP 请求的问题。
|
||||
|
||||
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
|
||||
|
||||
```text
|
||||
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||
```
|
||||
|
||||
Linux 客户端部署时建议改成:
|
||||
|
||||
```text
|
||||
TARGET_PLATFORM=linux
|
||||
RELEASE_MAIN_EXECUTABLE=bin/SimCAE
|
||||
CLIENT_MAIN_EXECUTABLE=SimCAE
|
||||
CLIENT_LAUNCHER_EXECUTABLE=Launcher
|
||||
CLIENT_UPDATER_EXECUTABLE=Updater
|
||||
CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap
|
||||
```
|
||||
|
||||
`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
|
||||
|
||||
## 七、常用命令
|
||||
@@ -408,10 +448,10 @@ CLIENT_API_TOKEN=...
|
||||
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||
```
|
||||
|
||||
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
|
||||
这个地址必须从客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
|
||||
|
||||
## 十、安全提醒
|
||||
|
||||
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
|
||||
|
||||
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
|
||||
默认用户名、默认密码、默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
|
||||
@@ -0,0 +1,310 @@
|
||||
# SimCAE 崩溃报告接口联调指南
|
||||
|
||||
本文说明崩溃报告后端接口怎么测试,以及 curl 示例里的 `dev-token` 应该替换成什么。
|
||||
|
||||
最快结论:
|
||||
|
||||
- 上传崩溃报告时,`dev-token` 替换成 `CRASH_REPORT_TOKEN` 的值。
|
||||
- 上传符号包时,`dev-token` 替换成 `CRASH_SYMBOL_TOKEN` 的值。
|
||||
- 查询或下载崩溃报告时,优先用 `CRASH_ADMIN_TOKEN`;如果它为空,就用 `ADMIN_TOKEN`。
|
||||
- 管理后台“客户端配置生成”区域下方会显示这些联调信息,方便复制。
|
||||
|
||||
## 一、先搞清楚三个 Token
|
||||
|
||||
curl 示例里的:
|
||||
|
||||
```text
|
||||
Authorization: Bearer dev-token
|
||||
```
|
||||
|
||||
这里的 `dev-token` 只是占位符,不是固定值。实际要按接口用途替换成服务端 `.env` / `.env.docker.example` 里的 token。
|
||||
|
||||
| 使用场景 | 请求头里填什么 | 服务端配置字段 |
|
||||
| --- | --- | --- |
|
||||
| 客户端上传崩溃报告 | `Authorization: Bearer <CRASH_REPORT_TOKEN>` | `CRASH_REPORT_TOKEN` |
|
||||
| 上传符号包 | `Authorization: Bearer <CRASH_SYMBOL_TOKEN>` | `CRASH_SYMBOL_TOKEN` |
|
||||
| 查询/下载崩溃报告原始文件 | `Authorization: Bearer <CRASH_ADMIN_TOKEN>` | `CRASH_ADMIN_TOKEN` |
|
||||
| `CRASH_ADMIN_TOKEN` 为空时查询/下载 | `Authorization: Bearer <ADMIN_TOKEN>` | `ADMIN_TOKEN` |
|
||||
|
||||
当前默认配置通常是:
|
||||
|
||||
```env
|
||||
CRASH_REPORT_TOKEN=SimCAECrashReportToken2026
|
||||
CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026
|
||||
CRASH_ADMIN_TOKEN=
|
||||
ADMIN_TOKEN=SimCAEAdminToken2026
|
||||
```
|
||||
|
||||
所以,如果使用默认配置:
|
||||
|
||||
- 上传崩溃报告用 `SimCAECrashReportToken2026`。
|
||||
- 上传符号包用 `SimCAESymbolToken2026`。
|
||||
- 查询/下载崩溃报告时,因为 `CRASH_ADMIN_TOKEN` 为空,所以用 `SimCAEAdminToken2026`。
|
||||
|
||||
管理后台“客户端配置生成”区域下方也会显示“崩溃报告接口联调信息”。这部分只给管理员或测试人员查看,不会复制进客户端 `app_config.json`,也不要手动放进客户端配置文件。
|
||||
|
||||
其中会以类似 `.env` 的形式把 `CRASH_REPORT_TOKEN`、`CRASH_SYMBOL_TOKEN`、`CRASH_ADMIN_TOKEN`、`ADMIN_TOKEN` 单独列出来,并在每个字段前加中文说明。`CRASH_ADMIN_TOKEN` 如果为空,查询和下载崩溃报告时就使用 `ADMIN_TOKEN`。
|
||||
|
||||
## 二、接口地址
|
||||
|
||||
假设服务端地址是:
|
||||
|
||||
```text
|
||||
http://192.168.1.158:8000
|
||||
```
|
||||
|
||||
下面示例里用变量表示:
|
||||
|
||||
```bash
|
||||
SERVER=http://192.168.1.158:8000
|
||||
REPORT_TOKEN=SimCAECrashReportToken2026
|
||||
SYMBOL_TOKEN=SimCAESymbolToken2026
|
||||
ADMIN_TOKEN=SimCAEAdminToken2026
|
||||
```
|
||||
|
||||
如果部署了 HTTPS 或域名,把 `SERVER` 改成实际地址即可。
|
||||
|
||||
## 三、健康检查
|
||||
|
||||
健康检查不需要 token:
|
||||
|
||||
```bash
|
||||
curl "$SERVER/api/v1/health"
|
||||
```
|
||||
|
||||
正常返回类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "simcae-crash-server",
|
||||
"version": "1.0.0",
|
||||
"timeUtc": "2026-07-14T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 四、准备测试文件
|
||||
|
||||
创建一个假的 minidump 文件:
|
||||
|
||||
```bash
|
||||
printf "dummy minidump for api test\n" > crash.dmp
|
||||
```
|
||||
|
||||
附件是可选的。需要测试附件时可以准备一个 zip:
|
||||
|
||||
```bash
|
||||
mkdir -p attachments
|
||||
printf "extra log\n" > attachments/runtime.log
|
||||
zip -r attachments.zip attachments
|
||||
```
|
||||
|
||||
如果服务器上没有 `zip`,可以先不传 `attachments` 字段。
|
||||
|
||||
## 五、准备 metadata.json
|
||||
|
||||
`clientReportId` 必须是 UUID,并且请求头 `Idempotency-Key` 必须和它完全一致。
|
||||
|
||||
最小可用示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"clientReportId": "8f6c5ed9-9a2b-4ef1-89a2-8843f2f7d9d1",
|
||||
"crashTimeUtc": "2026-07-14T08:30:00Z",
|
||||
"product": "SIMCAE",
|
||||
"appVersion": "0.10.3",
|
||||
"gitCommit": "unknown",
|
||||
"buildType": "Release",
|
||||
"channel": "stable",
|
||||
"platform": {
|
||||
"os": "Windows",
|
||||
"arch": "x64"
|
||||
},
|
||||
"crash": {
|
||||
"exceptionCode": "0xC0000005",
|
||||
"threadId": 1
|
||||
},
|
||||
"userConsent": {
|
||||
"uploadAllowed": true
|
||||
},
|
||||
"files": []
|
||||
}
|
||||
```
|
||||
|
||||
保存成 `metadata.json`。
|
||||
|
||||
说明:
|
||||
|
||||
- `product` 必须是 `SIMCAE`。
|
||||
- `schemaVersion` 必须是数字 `1`。
|
||||
- `files` 必须是数组,可以先填空数组。
|
||||
- 如果 `files` 里声明了 `minidump` 的 `sha256`,服务端会校验它必须和实际上传的 `crash.dmp` 一致。
|
||||
|
||||
## 六、上传崩溃报告
|
||||
|
||||
不带附件的最小上传:
|
||||
|
||||
```bash
|
||||
curl -X POST "$SERVER/api/v1/crash-reports" \
|
||||
-H "Authorization: Bearer $REPORT_TOKEN" \
|
||||
-H "Idempotency-Key: 8f6c5ed9-9a2b-4ef1-89a2-8843f2f7d9d1" \
|
||||
-H "X-SimCAE-Client: SIMCAE" \
|
||||
-H "X-SimCAE-Version: 0.10.3" \
|
||||
-F "metadata=@metadata.json;type=application/json" \
|
||||
-F "minidump=@crash.dmp;type=application/octet-stream"
|
||||
```
|
||||
|
||||
带附件上传:
|
||||
|
||||
```bash
|
||||
curl -X POST "$SERVER/api/v1/crash-reports" \
|
||||
-H "Authorization: Bearer $REPORT_TOKEN" \
|
||||
-H "Idempotency-Key: 8f6c5ed9-9a2b-4ef1-89a2-8843f2f7d9d1" \
|
||||
-H "X-SimCAE-Client: SIMCAE" \
|
||||
-H "X-SimCAE-Version: 0.10.3" \
|
||||
-F "metadata=@metadata.json;type=application/json" \
|
||||
-F "minidump=@crash.dmp;type=application/octet-stream" \
|
||||
-F "attachments=@attachments.zip;type=application/zip"
|
||||
```
|
||||
|
||||
正常首次上传返回 HTTP 201,类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"reportId": "srv_xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"duplicate": false,
|
||||
"status": "stored",
|
||||
"acceptedAtUtc": "2026-07-14T08:31:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
如果用同一个 `clientReportId` 和相同内容重复上传,会返回 HTTP 200,并且:
|
||||
|
||||
```json
|
||||
{
|
||||
"duplicate": true
|
||||
}
|
||||
```
|
||||
|
||||
如果同一个 `clientReportId` 但内容变了,会返回 HTTP 409,表示幂等冲突。
|
||||
|
||||
## 七、查询崩溃报告详情
|
||||
|
||||
把上传返回的 `reportId` 填到下面命令里:
|
||||
|
||||
```bash
|
||||
REPORT_ID=srv_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
curl "$SERVER/api/v1/crash-reports/$REPORT_ID" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 这里用的是管理查询 token。
|
||||
- 如果 `.env` 里配置了 `CRASH_ADMIN_TOKEN`,就用 `CRASH_ADMIN_TOKEN`。
|
||||
- 如果 `CRASH_ADMIN_TOKEN` 为空,就用 `ADMIN_TOKEN`。
|
||||
|
||||
## 八、下载崩溃报告文件
|
||||
|
||||
可下载文件名:
|
||||
|
||||
```text
|
||||
metadata.json
|
||||
crash.dmp
|
||||
attachments.zip
|
||||
server.json
|
||||
```
|
||||
|
||||
下载 minidump:
|
||||
|
||||
```bash
|
||||
curl "$SERVER/api/v1/crash-reports/$REPORT_ID/files/crash.dmp" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-o crash_downloaded.dmp
|
||||
```
|
||||
|
||||
下载 metadata:
|
||||
|
||||
```bash
|
||||
curl "$SERVER/api/v1/crash-reports/$REPORT_ID/files/metadata.json" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-o metadata_downloaded.json
|
||||
```
|
||||
|
||||
管理后台也可以查看崩溃报告列表和下载文件。
|
||||
|
||||
## 九、上传符号包
|
||||
|
||||
符号包接口用于上传某个版本/构建对应的 symbols.zip。
|
||||
|
||||
准备 `symbols_metadata.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"product": "SIMCAE",
|
||||
"appVersion": "0.10.3",
|
||||
"gitCommit": "unknown",
|
||||
"buildType": "Release",
|
||||
"platform": "windows-x64",
|
||||
"toolchain": "msvc2019",
|
||||
"createdAtUtc": "2026-07-14T08:00:00Z",
|
||||
"files": []
|
||||
}
|
||||
```
|
||||
|
||||
准备测试符号包:
|
||||
|
||||
```bash
|
||||
mkdir -p symbols
|
||||
printf "dummy symbols\n" > symbols/readme.txt
|
||||
zip -r symbols.zip symbols
|
||||
```
|
||||
|
||||
上传:
|
||||
|
||||
```bash
|
||||
curl -X POST "$SERVER/api/v1/symbols" \
|
||||
-H "Authorization: Bearer $SYMBOL_TOKEN" \
|
||||
-F "metadata=@symbols_metadata.json;type=application/json" \
|
||||
-F "symbols=@symbols.zip;type=application/zip"
|
||||
```
|
||||
|
||||
正常返回类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"symbolUploadId": "sym_xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"status": "stored",
|
||||
"duplicate": false,
|
||||
"acceptedAtUtc": "2026-07-14T08:35:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 十、常见错误
|
||||
|
||||
1. HTTP 401 `missing bearer token`
|
||||
没有传 `Authorization: Bearer ...`,或者格式写错。
|
||||
|
||||
2. HTTP 401 `invalid bearer token`
|
||||
token 值不对。检查 `.env` 里的 `CRASH_REPORT_TOKEN`、`CRASH_SYMBOL_TOKEN`、`CRASH_ADMIN_TOKEN`、`ADMIN_TOKEN`。
|
||||
|
||||
3. HTTP 403 `token is not allowed to access this endpoint`
|
||||
token 是对的,但用错接口了。例如拿 `CRASH_REPORT_TOKEN` 去查询/下载报告。
|
||||
|
||||
4. HTTP 400 `Idempotency-Key must equal metadata.clientReportId`
|
||||
请求头 `Idempotency-Key` 必须等于 metadata 里的 `clientReportId`。
|
||||
|
||||
5. HTTP 400 `metadata.product must be SIMCAE`
|
||||
metadata 里的 `product` 必须填 `SIMCAE`。
|
||||
|
||||
6. HTTP 400 `file_hash_mismatch`
|
||||
metadata.files 里声明的 SHA-256 和实际上传文件不一致。联调初期可以先把 `files` 填成空数组。
|
||||
|
||||
7. HTTP 413 `payload_too_large`
|
||||
文件超过 `.env` 里的大小限制,例如 `CRASH_MINIDUMP_MAX_MB`、`CRASH_ATTACHMENTS_MAX_MB`、`CRASH_REQUEST_MAX_MB`。
|
||||
|
||||
8. HTTP 409 `idempotency_conflict`
|
||||
同一个 `clientReportId` 已经上传过,但这次内容不一样。换一个新的 UUID 再试。
|
||||
@@ -1,15 +1,33 @@
|
||||
# 后端成熟模板化改造说明
|
||||
# 后端工程化结构说明
|
||||
|
||||
本文给后端维护人员阅读。它说明服务端为什么从单个 `main.py` 拆成 `routes / services / repositories / schemas`,以及后续继续工程化时应该把代码放在哪里。
|
||||
|
||||
## 目标
|
||||
|
||||
服务端后端不再继续把所有配置、鉴权、审计、页面入口和业务接口堆在 `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 Users:用户、认证、JWT、密码哈希、用户管理。
|
||||
- 现有 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/
|
||||
router.py
|
||||
routes/
|
||||
admin_auth.py
|
||||
admin_license.py
|
||||
admin_policy.py
|
||||
admin_version.py
|
||||
@@ -37,6 +56,7 @@ app/
|
||||
audit.py
|
||||
db/
|
||||
repositories/
|
||||
admin_user_repository.py
|
||||
app_channel_repository.py
|
||||
crash_report_repository.py
|
||||
device_repository.py
|
||||
@@ -47,6 +67,7 @@ app/
|
||||
client_update_repository.py
|
||||
publish_repository.py
|
||||
schemas/
|
||||
admin_auth.py
|
||||
license.py
|
||||
policy.py
|
||||
version.py
|
||||
@@ -68,7 +89,7 @@ app/
|
||||
当前已经从 `main.py` 抽出的通用能力:
|
||||
|
||||
- `app/core/config.py`:统一读取 `.env`、路径配置和基础 settings。
|
||||
- `app/core/security.py`:后台令牌校验、令牌摘要、`.env` 写入工具。
|
||||
- `app/core/security.py`:管理员 JWT、Argon2 密码哈希、RBAC 权限检查、令牌摘要、`.env` 写入工具。
|
||||
- `app/core/audit.py`:管理后台写操作审计中间件。
|
||||
- `app/api/routes/frontend.py`:管理后台静态页面、favicon、platform-config、health 路由。
|
||||
- `app/api/routes/admin_license.py`:License 创建、列表、禁用/启用、软删除接口。
|
||||
@@ -78,7 +99,8 @@ app/
|
||||
- `app/api/routes/admin_logs.py`:升级日志、下载日志、管理员审计日志的列表、删除和清空接口。
|
||||
- `app/api/routes/admin_crash_report.py`:崩溃报告管理列表和附件下载接口。
|
||||
- `app/api/routes/admin_device.py`:设备列表和设备禁用/恢复接口。
|
||||
- `app/api/routes/admin_config.py`:管理员令牌校验/变更、运行时配置、客户端配置生成接口。
|
||||
- `app/api/routes/admin_auth.py`:用户名密码登录、JWT 刷新、登录检查、修改密码、管理员用户列表、创建、角色编辑、禁用/启用和重置密码接口。
|
||||
- `app/api/routes/admin_config.py`:运行时配置、客户端配置生成接口,以及服务端兜底令牌变更接口。
|
||||
- `app/api/routes/admin_publish.py`:管理端发布新版本接口,包含发布锁和管理员鉴权。
|
||||
- `app/api/routes/client_update.py`:客户端设备登记、更新检测、下载链接、Manifest、下载日志和升级结果上报接口。
|
||||
- `app/api/routes/crash_api.py`:崩溃报告健康检查、报告上传/下载和符号包上传接口。
|
||||
@@ -93,21 +115,47 @@ app/
|
||||
- `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。
|
||||
- `app/api/router.py`:统一注册基础路由。
|
||||
- `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。
|
||||
- `tests/`:自动化测试入口,覆盖管理员登录/JWT、License/设备登记、更新检查、版本发布、崩溃报告上传幂等和管理员查询。
|
||||
|
||||
当前业务接口路径保持不变,避免影响 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。
|
||||
2. 将 `ADMIN_TOKEN` 替换成管理员用户名/密码登录、JWT、refresh token。
|
||||
3. 增加用户、角色、权限、菜单权限、API 权限表。
|
||||
4. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/`。
|
||||
5. 继续将请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型。
|
||||
6. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰。
|
||||
7. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层。
|
||||
8. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic。
|
||||
1. 继续接入更完整的成熟用户认证模块,例如 FastAPI Users,替换当前轻量用户表。
|
||||
2. 将现有权限点继续细化,例如 `version:publish`、`license:create`、`audit:read`。
|
||||
3. 管理后台常用业务接口、客户端更新接口、版本发布接口、崩溃报告接口已经按领域拆到 `app/api/routes/`,并将 SQL 下沉到 `app/repositories/`。
|
||||
4. 继续将请求/响应 `dict` 改成 `app/schemas/` 下的 Pydantic 模型。
|
||||
5. 继续补齐请求/响应模型、权限模型、异常模型,让接口契约更清晰。
|
||||
6. 继续细化 repository,后续可把 SQLite SQL 逐步迁到 ORM 或统一查询层。
|
||||
7. 引入 ORM 和迁移工具,例如 SQLModel/SQLAlchemy + Alembic。
|
||||
8. 增加登录失败审计、登录限流、会话管理和更细粒度 Token 轮换。
|
||||
9. 保持客户端接口 `/api/v1/...` 尽量兼容,避免客户端 SDK 重编。
|
||||
|
||||
## 为什么不是直接删除 main.py
|
||||
@@ -1,38 +0,0 @@
|
||||
服务端文档入口
|
||||
==============
|
||||
|
||||
本目录用于保存 update-server 服务端子仓库的说明文档。
|
||||
|
||||
建议阅读顺序:
|
||||
|
||||
1. 服务端部署说明.md
|
||||
面向部署和运维,说明 Docker 离线包里有什么、怎么部署、.env 每个字段怎么填、怎么查看日志和备份数据。
|
||||
|
||||
2. 后端模板化改造说明.md
|
||||
面向后端维护,说明当前 FastAPI 后端如何按 routes/services/repositories/schemas 分层,以及后续工程化方向。
|
||||
|
||||
常用源码入口:
|
||||
|
||||
- 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
|
||||
```
|
||||
+4
-6
@@ -1,16 +1,14 @@
|
||||
FROM node:20-alpine as build-stage
|
||||
FROM node:22-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@latest --activate
|
||||
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
COPY .npmrc package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY .npmrc package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<h1>vue-pure-admin Lite Edition(no i18n version)</h1>
|
||||
|
||||
[](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
@@ -1,43 +1,34 @@
|
||||
<h1>vue-pure-admin精简版(非国际化版本)</h1>
|
||||
# SimCAE 管理后台
|
||||
|
||||
[](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)
|
||||
[点我查看快速开发教程](https://www.bilibili.com/video/BV1kg411v7QT)
|
||||
构建产物输出到 `dist/`。服务端 Dockerfile 会自动构建并复制 `dist/`,通常不需要手动复制。
|
||||
|
||||
## 配套保姆级文档
|
||||
## 包管理约定
|
||||
|
||||
[点我查看 vue-pure-admin 文档](https://pure-admin.cn/)
|
||||
[点我查看 @pureadmin/utils 文档](https://pure-admin-utils.netlify.app)
|
||||
本项目统一使用 `npm` 和 `package-lock.json`。不要再提交 `pnpm-lock.yaml` 或 `yarn.lock`,避免不同包管理器解析出不同依赖版本。
|
||||
|
||||
## 高级服务
|
||||
## 模板来源
|
||||
|
||||
[点我查看详情](https://pure-admin.cn/pages/service/)
|
||||
|
||||
## 预览
|
||||
|
||||
[查看预览](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)
|
||||
初始工程来自开源后台模板 `pure-admin-thin`。当前仓库只保留 SimCAE 后台需要的页面和运行能力,示例页面不作为业务交付内容。
|
||||
|
||||
Generated
-7387
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { getToken } from "@/utils/auth";
|
||||
import { formatToken, getToken, removeToken } from "@/utils/auth";
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
status: number;
|
||||
@@ -13,7 +13,14 @@ export class AdminApiError extends Error {
|
||||
}
|
||||
|
||||
export function adminToken() {
|
||||
return getToken()?.accessToken || localStorage.getItem("admin_token") || "";
|
||||
return getToken()?.accessToken || "";
|
||||
}
|
||||
|
||||
function applyAdminAuth(headers: Headers) {
|
||||
const token = adminToken();
|
||||
if (token) {
|
||||
headers.set("Authorization", formatToken(token));
|
||||
}
|
||||
}
|
||||
|
||||
export function friendlyError(error: unknown) {
|
||||
@@ -37,8 +44,7 @@ export async function adminRequest<T = any>(
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = adminToken();
|
||||
if (token) headers.set("X-Admin-Token", token);
|
||||
applyAdminAuth(headers);
|
||||
|
||||
let body = options.body;
|
||||
if (options.json !== undefined) {
|
||||
@@ -64,6 +70,12 @@ export async function adminRequest<T = any>(
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
removeToken();
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
throw new AdminApiError(
|
||||
`HTTP ${response.status}: ${text || response.statusText}`,
|
||||
response.status,
|
||||
@@ -100,7 +112,7 @@ export async function downloadAdminFile(
|
||||
} = {}
|
||||
) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set("X-Admin-Token", adminToken());
|
||||
applyAdminAuth(headers);
|
||||
|
||||
let body = options.body;
|
||||
if (options.json !== undefined) {
|
||||
@@ -116,6 +128,12 @@ export async function downloadAdminFile(
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401) {
|
||||
removeToken();
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
throw new AdminApiError(
|
||||
`HTTP ${response.status}: ${text || response.statusText}`,
|
||||
response.status,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import {
|
||||
type DataInfo,
|
||||
userKey,
|
||||
getToken,
|
||||
removeToken,
|
||||
multipleTabsKey
|
||||
} from "@/utils/auth";
|
||||
@@ -147,7 +148,8 @@ router.beforeEach((to: ToRouteType, _from, next) => {
|
||||
function toCorrectRoute() {
|
||||
whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
|
||||
}
|
||||
if (Cookies.get(multipleTabsKey) && userInfo) {
|
||||
const accessToken = getToken()?.accessToken;
|
||||
if (Cookies.get(multipleTabsKey) && userInfo && accessToken) {
|
||||
// 无权限跳转403页面
|
||||
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
|
||||
next({ path: "/error/403" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -11,12 +11,13 @@ import { useLayout } from "@/layout/hooks/useLayout";
|
||||
import { initRouter, getTopMenu } from "@/router/utils";
|
||||
import { bg, avatar, illustration } from "./utils/static";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import { setToken } from "@/utils/auth";
|
||||
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||
|
||||
import dayIcon from "@/assets/svg/day.svg?component";
|
||||
import darkIcon from "@/assets/svg/dark.svg?component";
|
||||
import Lock from "~icons/ri/lock-fill";
|
||||
import User from "~icons/ri/user-3-fill";
|
||||
import { useUserStoreHook } from "@/store/modules/user";
|
||||
|
||||
defineOptions({
|
||||
name: "Login"
|
||||
@@ -33,50 +34,32 @@ initStorage();
|
||||
const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange();
|
||||
dataThemeChange(overallStyle.value);
|
||||
const { title } = useNav();
|
||||
const userStore = useUserStoreHook();
|
||||
|
||||
const ruleForm = reactive({
|
||||
adminToken: localStorage.getItem("admin_token") || ""
|
||||
username: localStorage.getItem("admin_username") || "admin",
|
||||
password: ""
|
||||
});
|
||||
|
||||
async function checkAdminToken(adminToken: string) {
|
||||
const response = await fetch("/admin/auth/check", {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"X-Admin-Token": adminToken
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "管理员令牌验证失败");
|
||||
}
|
||||
}
|
||||
|
||||
const onLogin = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate(async valid => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const adminToken = ruleForm.adminToken.trim();
|
||||
await checkAdminToken(adminToken);
|
||||
localStorage.setItem("admin_token", adminToken);
|
||||
setToken({
|
||||
accessToken: adminToken,
|
||||
refreshToken: adminToken,
|
||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
avatar: "",
|
||||
username: "admin",
|
||||
nickname: "管理员",
|
||||
roles: ["admin"],
|
||||
permissions: ["*:*:*"]
|
||||
const username = ruleForm.username.trim();
|
||||
await userStore.loginByUsername({
|
||||
username,
|
||||
password: ruleForm.password
|
||||
});
|
||||
localStorage.setItem("admin_username", username);
|
||||
await initRouter();
|
||||
disabled.value = true;
|
||||
await router.push(getTopMenu(true).path);
|
||||
message("登录成功", { type: "success" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message("管理员令牌错误或服务端不可访问", { type: "error" });
|
||||
message("用户名、密码错误或服务端不可访问", { type: "error" });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
disabled.value = false;
|
||||
@@ -126,20 +109,40 @@ useEventListener(document, "keydown", ({ code }) => {
|
||||
<el-form ref="ruleFormRef" :model="ruleForm" size="large">
|
||||
<Motion :delay="100">
|
||||
<el-form-item
|
||||
prop="adminToken"
|
||||
prop="username"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入管理员令牌',
|
||||
message: '请输入用户名',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model="ruleForm.adminToken"
|
||||
v-model="ruleForm.username"
|
||||
clearable
|
||||
placeholder="用户名"
|
||||
:prefix-icon="useRenderIcon(User)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</Motion>
|
||||
|
||||
<Motion :delay="180">
|
||||
<el-form-item
|
||||
prop="password"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model="ruleForm.password"
|
||||
clearable
|
||||
show-password
|
||||
placeholder="管理员令牌"
|
||||
placeholder="密码"
|
||||
:prefix-icon="useRenderIcon(Lock)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@@ -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>
|
||||
+1398
-122
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() || ""));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
||||
|
||||
from app.api.routes import (
|
||||
admin_app_channel,
|
||||
admin_auth,
|
||||
admin_config,
|
||||
admin_crash_report,
|
||||
admin_device,
|
||||
@@ -13,6 +14,7 @@ from app.api.routes import (
|
||||
client_update,
|
||||
crash_api,
|
||||
frontend,
|
||||
git_tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +22,7 @@ def include_api_routes(app: FastAPI):
|
||||
frontend.mount_admin_assets(app)
|
||||
crash_api.register_exception_handlers(app)
|
||||
app.include_router(frontend.router)
|
||||
app.include_router(admin_auth.router)
|
||||
app.include_router(admin_policy.router)
|
||||
app.include_router(admin_version.router)
|
||||
app.include_router(admin_license.router)
|
||||
@@ -29,5 +32,6 @@ def include_api_routes(app: FastAPI):
|
||||
app.include_router(admin_logs.router)
|
||||
app.include_router(admin_crash_report.router)
|
||||
app.include_router(admin_device.router)
|
||||
app.include_router(git_tags.router)
|
||||
app.include_router(client_update.router)
|
||||
app.include_router(crash_api.router)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import app_channel_repository
|
||||
from app.schemas.app_channel import AppCreateRequest, ChannelSaveRequest
|
||||
from app.services.common_service import validate_channel_code
|
||||
@@ -10,12 +10,12 @@ router = APIRouter(tags=["admin-app-channel"])
|
||||
|
||||
|
||||
@router.get("/admin/app/list")
|
||||
def admin_get_app_list(auth=Depends(admin_auth)):
|
||||
def admin_get_app_list(auth=Depends(require_permission("app:view"))):
|
||||
return {"list": app_channel_repository.list_apps()}
|
||||
|
||||
|
||||
@router.post("/admin/app/add")
|
||||
def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
|
||||
def admin_add_app(body: AppCreateRequest, auth=Depends(require_permission("app:manage"))):
|
||||
aid = body.app_id
|
||||
aname = body.app_name
|
||||
try:
|
||||
@@ -26,12 +26,12 @@ def admin_add_app(body: AppCreateRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.get("/admin/channel/list")
|
||||
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
|
||||
def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(require_permission("app:view"))):
|
||||
return {"list": app_channel_repository.list_channels(app_id, include_disabled)}
|
||||
|
||||
|
||||
@router.post("/admin/channel/save")
|
||||
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(admin_auth)):
|
||||
def admin_channel_save(body: ChannelSaveRequest, auth=Depends(require_permission("channel:manage"))):
|
||||
app_id = body.app_id.strip()
|
||||
code = body.channel_code.strip()
|
||||
name = body.display_name.strip()
|
||||
|
||||
@@ -0,0 +1,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": "密码已更新,请重新登录"}
|
||||
@@ -1,11 +1,12 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.core.config import ENV_FILE_PATH
|
||||
from app.core.security import admin_auth, persist_env_value, set_admin_token
|
||||
from app.repositories import app_channel_repository
|
||||
from app.core.security import persist_env_value, require_permission, set_admin_token, token_digest
|
||||
from app.repositories import app_channel_repository, license_repository
|
||||
from app.schemas.admin_config import ChangeAdminTokenRequest, ClientConfigGenerateRequest
|
||||
from app.services.admin_config_service import (
|
||||
PUBLISH_MAX_FIELDS,
|
||||
@@ -15,7 +16,10 @@ from app.services.admin_config_service import (
|
||||
TARGET_ARCH,
|
||||
TARGET_PLATFORM,
|
||||
default_client_config_values,
|
||||
crash_report_test_values,
|
||||
crash_report_test_text,
|
||||
normalize_executable_name,
|
||||
platform_executable_path,
|
||||
public_api_base_url,
|
||||
)
|
||||
from app.services.common_service import validate_channel_code
|
||||
@@ -24,29 +28,58 @@ from app.services.common_service import validate_channel_code
|
||||
router = APIRouter(tags=["admin-config"])
|
||||
|
||||
|
||||
@router.get("/admin/auth/check")
|
||||
def admin_auth_check(auth=Depends(admin_auth)):
|
||||
return {"code": 0, "msg": "管理员令牌有效"}
|
||||
def client_config_license_warning(license_key: str, app_id: str, channel: str) -> str:
|
||||
if not license_key:
|
||||
return ""
|
||||
license_row = license_repository.get_license_by_hash_with_usage(token_digest(license_key))
|
||||
if not license_row:
|
||||
return "当前 License 不存在或已删除,生成的客户端配置未写入 License,请创建或申请新的 License。"
|
||||
if license_row["app_id"] != app_id or license_row["channel_code"] != channel:
|
||||
return "当前 License 不属于所选应用或渠道,生成的客户端配置未写入 License,请选择匹配的 License。"
|
||||
if license_row["status"] != "active":
|
||||
return "当前 License 已禁用,生成的客户端配置未写入 License,请启用该 License 或创建新的 License。"
|
||||
try:
|
||||
expiry = datetime.fromisoformat(str(license_row["valid_until"]).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
expiry = datetime.now(timezone.utc)
|
||||
if expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
if expiry <= datetime.now(timezone.utc):
|
||||
return "当前 License 已过期,生成的客户端配置未写入 License,请创建或申请新的 License。"
|
||||
used_devices = int(license_row["used_devices"] or 0)
|
||||
max_devices = int(license_row["max_devices"] or 0)
|
||||
if used_devices >= max_devices:
|
||||
return f"当前 License 已绑定 {used_devices}/{max_devices} 台设备,已达到上限。生成的客户端配置未写入 License,请创建或申请新的 License。"
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_install_root(value: str, fallback: str = "..") -> str:
|
||||
normalized = str(value or fallback).strip().replace("\\", "/")
|
||||
if normalized in ("", "."):
|
||||
return "."
|
||||
if normalized == "..":
|
||||
return ".."
|
||||
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
|
||||
|
||||
|
||||
@router.post("/admin/token/change")
|
||||
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(admin_auth)):
|
||||
def admin_change_token(body: ChangeAdminTokenRequest, auth=Depends(require_permission("admin:manage"))):
|
||||
new_token = body.new_token.strip()
|
||||
if len(new_token) < 8:
|
||||
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
|
||||
raise HTTPException(status_code=400, detail="新服务端兜底令牌至少需要 8 个字符")
|
||||
if len(new_token) > 128:
|
||||
raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符")
|
||||
raise HTTPException(status_code=400, detail="新服务端兜底令牌不能超过 128 个字符")
|
||||
try:
|
||||
persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}")
|
||||
set_admin_token(new_token)
|
||||
os.environ["ADMIN_TOKEN"] = new_token
|
||||
return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
|
||||
return {"code": 0, "msg": f"服务端兜底令牌已更新,并已写入 {ENV_FILE_PATH}"}
|
||||
|
||||
|
||||
@router.get("/admin/runtime-config")
|
||||
def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
|
||||
def admin_runtime_config(request: Request, auth=Depends(require_permission("config:view"))):
|
||||
return {
|
||||
"release_main_executable": RELEASE_MAIN_EXECUTABLE,
|
||||
"target_platform": TARGET_PLATFORM,
|
||||
@@ -60,7 +93,7 @@ def admin_runtime_config(request: Request, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/client-config/generate")
|
||||
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(admin_auth)):
|
||||
def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Request, auth=Depends(require_permission("config:view"))):
|
||||
app_id = body.app_id.strip()
|
||||
channel = body.channel.strip() or "stable"
|
||||
current_version = body.current_version.strip() or "1.0.0"
|
||||
@@ -81,7 +114,15 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
|
||||
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
|
||||
|
||||
defaults = default_client_config_values(request)
|
||||
main_executable = normalize_executable_name(body.main_executable) or defaults["main_executable"]
|
||||
install_root = normalize_install_root(body.install_root, defaults["install_root"])
|
||||
main_executable = platform_executable_path(
|
||||
normalize_executable_name(body.main_executable),
|
||||
defaults["main_executable"],
|
||||
)
|
||||
license_key = body.license_key.strip()
|
||||
license_warning = client_config_license_warning(license_key, app_id, channel)
|
||||
if license_warning:
|
||||
license_key = ""
|
||||
config = {
|
||||
"app_id": app_id,
|
||||
"app_name": app_row["app_name"],
|
||||
@@ -89,13 +130,12 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
|
||||
"current_version": current_version,
|
||||
"client_protocol": str(client_protocol),
|
||||
"launch_token": defaults["launch_token"],
|
||||
"license_key": body.license_key.strip(),
|
||||
"api_base_url": api_base_url,
|
||||
"license_key": license_key,
|
||||
"client_token": defaults["client_token"],
|
||||
"request_timeout_ms": defaults["request_timeout_ms"],
|
||||
"temp_folder": defaults["temp_folder"],
|
||||
"device_id": "",
|
||||
"install_root": defaults["install_root"],
|
||||
"install_root": install_root,
|
||||
"main_executable": main_executable,
|
||||
"launcher_executable": defaults["launcher_executable"],
|
||||
"updater_executable": defaults["updater_executable"],
|
||||
@@ -104,7 +144,16 @@ def admin_generate_client_config(body: ClientConfigGenerateRequest, request: Req
|
||||
"platform": defaults["platform"],
|
||||
"arch": defaults["arch"],
|
||||
}
|
||||
crash_test_config = crash_report_test_values(api_base_url)
|
||||
server_config = {
|
||||
"api_base_url": api_base_url,
|
||||
}
|
||||
return {
|
||||
"config": config,
|
||||
"json_text": json.dumps(config, ensure_ascii=False, indent=2),
|
||||
"license_warning": license_warning,
|
||||
"server_config": server_config,
|
||||
"server_config_text": json.dumps(server_config, ensure_ascii=False, indent=2),
|
||||
"crash_test_config": crash_test_config,
|
||||
"crash_test_text": crash_report_test_text(api_base_url),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.security import admin_auth, token_digest
|
||||
from app.core.security import AdminPrincipal, require_permission
|
||||
from app.repositories import crash_report_repository
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ router = APIRouter(tags=["admin-crash-report"])
|
||||
|
||||
|
||||
@router.get("/admin/crash-report/list")
|
||||
def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)):
|
||||
def admin_crash_report_list(limit: int = 300, auth=Depends(require_permission("crash:view"))):
|
||||
limit = max(1, min(limit, 1000))
|
||||
return {"list": crash_report_repository.list_crash_reports(limit)}
|
||||
|
||||
@@ -21,8 +21,7 @@ def admin_crash_report_file(
|
||||
report_id: str,
|
||||
file_name: str,
|
||||
request: Request,
|
||||
X_Admin_Token: str = Header(""),
|
||||
auth=Depends(admin_auth),
|
||||
auth: AdminPrincipal = Depends(require_permission("crash:view")),
|
||||
):
|
||||
allowed = {
|
||||
"metadata": "metadata.json",
|
||||
@@ -49,7 +48,7 @@ def admin_crash_report_file(
|
||||
crash_report_repository.log_file_access(
|
||||
report_id,
|
||||
stored_name,
|
||||
token_digest(X_Admin_Token)[:16],
|
||||
auth.actor_hash,
|
||||
request.client.host if request.client else "",
|
||||
request.headers.get("user-agent", "")[:300],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import device_repository
|
||||
from app.schemas.device import DeviceSetDisabledRequest
|
||||
|
||||
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-device"])
|
||||
|
||||
|
||||
@router.get("/admin/device/list")
|
||||
def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
|
||||
def admin_device_list(app_id: str = "", auth=Depends(require_permission("device:view"))):
|
||||
return {"list": device_repository.list_devices(app_id)}
|
||||
|
||||
|
||||
@router.post("/admin/device/set-disabled")
|
||||
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(admin_auth)):
|
||||
def admin_device_set_disabled(body: DeviceSetDisabledRequest, auth=Depends(require_permission("device:manage"))):
|
||||
device_id = body.device_id.strip()
|
||||
disabled = bool(body.disabled)
|
||||
reason = body.reason.strip()
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth, token_digest
|
||||
from app.core.security import require_permission, token_digest
|
||||
from app.repositories import license_repository
|
||||
from app.schemas.license import LicenseCreateRequest, LicenseDeleteRequest, LicenseStatusRequest
|
||||
from app.services.common_service import validate_channel_code
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["admin-license"])
|
||||
|
||||
|
||||
@router.post("/admin/license/create")
|
||||
def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
|
||||
def admin_license_create(body: LicenseCreateRequest, auth=Depends(require_permission("license:manage"))):
|
||||
app_id = body.app_id.strip()
|
||||
channel = body.channel.strip()
|
||||
customer = body.customer_name.strip()
|
||||
@@ -46,7 +46,7 @@ def admin_license_create(body: LicenseCreateRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.get("/admin/license/list")
|
||||
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(admin_auth)):
|
||||
def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Depends(require_permission("license:view"))):
|
||||
rows = license_repository.list_licenses(app_id, include_deleted)
|
||||
result = []
|
||||
for r in rows:
|
||||
@@ -59,7 +59,7 @@ def admin_license_list(app_id: str = "", include_deleted: bool = False, auth=Dep
|
||||
|
||||
|
||||
@router.post("/admin/license/set-status")
|
||||
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth)):
|
||||
def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(require_permission("license:manage"))):
|
||||
status = body.status
|
||||
license_id = body.license_id
|
||||
if status not in ("active", "disabled"):
|
||||
@@ -71,7 +71,7 @@ def admin_license_set_status(body: LicenseStatusRequest, auth=Depends(admin_auth
|
||||
|
||||
|
||||
@router.post("/admin/license/delete")
|
||||
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_license_delete(body: LicenseDeleteRequest, auth=Depends(require_permission("license:manage"))):
|
||||
license_id = body.license_id.strip()
|
||||
if not license_id:
|
||||
raise HTTPException(status_code=400, detail="license_id 不能为空")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import log_repository
|
||||
from app.schemas.log import DownloadLogClearRequest, LogDeleteRequest
|
||||
|
||||
@@ -9,12 +9,12 @@ router = APIRouter(tags=["admin-logs"])
|
||||
|
||||
|
||||
@router.get("/admin/report/list")
|
||||
def admin_get_report_log(auth=Depends(admin_auth)):
|
||||
def admin_get_report_log(auth=Depends(require_permission("log:view"))):
|
||||
return {"list": log_repository.list_upgrade_logs()}
|
||||
|
||||
|
||||
@router.post("/admin/report/delete")
|
||||
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
|
||||
log_id = int(body.id or 0)
|
||||
if log_id < 1:
|
||||
raise HTTPException(status_code=400, detail="日志 ID 无效")
|
||||
@@ -25,19 +25,19 @@ def admin_delete_report_log(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/report/clear")
|
||||
def admin_clear_report_logs(auth=Depends(admin_auth)):
|
||||
def admin_clear_report_logs(auth=Depends(require_permission("log:manage"))):
|
||||
deleted = log_repository.clear_upgrade_logs()
|
||||
return {"msg": "升级日志已清空", "deleted": deleted}
|
||||
|
||||
|
||||
@router.get("/admin/download-log/list")
|
||||
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(admin_auth)):
|
||||
def admin_download_log_list(app_id: str = "", limit: int = 300, auth=Depends(require_permission("log:view"))):
|
||||
limit = max(1, min(limit, 1000))
|
||||
return {"list": log_repository.list_download_logs(app_id, limit)}
|
||||
|
||||
|
||||
@router.post("/admin/download-log/delete")
|
||||
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
|
||||
log_id = int(body.id or 0)
|
||||
if log_id < 1:
|
||||
raise HTTPException(status_code=400, detail="日志 ID 无效")
|
||||
@@ -48,19 +48,19 @@ def admin_download_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/download-log/clear")
|
||||
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(admin_auth)):
|
||||
def admin_download_log_clear(body: DownloadLogClearRequest | None = Body(default=None), auth=Depends(require_permission("log:manage"))):
|
||||
app_id = (body.app_id if body else "").strip()
|
||||
deleted = log_repository.clear_download_logs(app_id)
|
||||
return {"msg": "下载日志已清空", "deleted": deleted}
|
||||
|
||||
|
||||
@router.get("/admin/audit-log/list")
|
||||
def admin_audit_log_list(limit: int = 300, auth=Depends(admin_auth)):
|
||||
def admin_audit_log_list(limit: int = 300, auth=Depends(require_permission("log:view"))):
|
||||
return {"list": log_repository.list_audit_logs(max(1, min(limit, 1000)))}
|
||||
|
||||
|
||||
@router.post("/admin/audit-log/delete")
|
||||
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(require_permission("log:manage"))):
|
||||
log_id = int(body.id or 0)
|
||||
if log_id < 1:
|
||||
raise HTTPException(status_code=400, detail="日志 ID 无效")
|
||||
@@ -71,6 +71,6 @@ def admin_audit_log_delete(body: LogDeleteRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/audit-log/clear")
|
||||
def admin_audit_log_clear(auth=Depends(admin_auth)):
|
||||
def admin_audit_log_clear(auth=Depends(require_permission("log:manage"))):
|
||||
deleted = log_repository.clear_audit_logs()
|
||||
return {"msg": "审计日志已清空", "deleted": deleted}
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import policy_repository
|
||||
from app.schemas.policy import PolicySaveRequest
|
||||
from app.services.common_service import policy_row_to_dict, validate_channel_code
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["admin-policy"])
|
||||
|
||||
|
||||
@router.get("/admin/policy")
|
||||
def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
|
||||
def admin_get_policy(app_id: str, channel: str, auth=Depends(require_permission("policy:view"))):
|
||||
row = policy_repository.get_policy(app_id, channel)
|
||||
result = policy_row_to_dict(row)
|
||||
result.update({"app_id": app_id, "channel": channel, "saved": bool(row)})
|
||||
@@ -21,7 +21,7 @@ def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/policy/save")
|
||||
def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
|
||||
def admin_save_policy(body: PolicySaveRequest, auth=Depends(require_permission("policy:manage"))):
|
||||
app_id = body.app_id.strip()
|
||||
channel = body.channel.strip()
|
||||
if not app_id or not validate_channel_code(channel):
|
||||
@@ -43,6 +43,7 @@ def admin_save_policy(body: PolicySaveRequest, auth=Depends(admin_auth)):
|
||||
valid_until,
|
||||
body.min_supported_version.strip(),
|
||||
json.dumps(disabled, ensure_ascii=False),
|
||||
bool(body.git_tags_enabled),
|
||||
body.message.strip(),
|
||||
)
|
||||
if result == "channel_not_found":
|
||||
|
||||
@@ -1,18 +1,93 @@
|
||||
import asyncio
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
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")
|
||||
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():
|
||||
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
||||
async with PUBLISH_LOCK:
|
||||
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)
|
||||
|
||||
@@ -7,22 +7,22 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import minio_tool
|
||||
from app.core.security import admin_auth
|
||||
from app.core.security import require_permission
|
||||
from app.repositories import version_repository
|
||||
from app.schemas.version import VersionIdRequest, VersionProtocolRequest
|
||||
from app.services.common_service import is_executable_path
|
||||
from app.services.signing_service import canonical_manifest_bytes, sign_manifest, sign_policy
|
||||
|
||||
|
||||
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
|
||||
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
|
||||
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
|
||||
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64").strip() or "x64"
|
||||
SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
|
||||
|
||||
router = APIRouter(tags=["admin-version"])
|
||||
|
||||
|
||||
@router.post("/admin/version/offline-package")
|
||||
def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
def admin_offline_package(body: VersionIdRequest, auth=Depends(require_permission("version:view"))):
|
||||
version_id = int(body.version_id or 0)
|
||||
ver, rows = version_repository.get_version_with_files(version_id)
|
||||
if not ver or not rows:
|
||||
@@ -87,12 +87,12 @@ def admin_offline_package(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.get("/admin/version/list")
|
||||
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
|
||||
def admin_get_version_list(app_id: str, auth=Depends(require_permission("version:view"))):
|
||||
return {"list": version_repository.list_versions(app_id)}
|
||||
|
||||
|
||||
@router.post("/admin/version/set-protocol")
|
||||
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_auth)):
|
||||
def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(require_permission("version:manage"))):
|
||||
version_id = int(body.version_id or 0)
|
||||
client_protocol = int(body.client_protocol or 0)
|
||||
if version_id < 1 or client_protocol < 1:
|
||||
@@ -104,7 +104,7 @@ def admin_set_version_protocol(body: VersionProtocolRequest, auth=Depends(admin_
|
||||
|
||||
|
||||
@router.post("/admin/version/set-latest")
|
||||
def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
def admin_set_latest(body: VersionIdRequest, auth=Depends(require_permission("version:manage"))):
|
||||
vid = body.version_id
|
||||
result = version_repository.set_latest_version(vid)
|
||||
if result == "not_found":
|
||||
@@ -113,7 +113,7 @@ def admin_set_latest(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
|
||||
|
||||
@router.post("/admin/version/delete")
|
||||
def admin_delete_version(body: VersionIdRequest, auth=Depends(admin_auth)):
|
||||
def admin_delete_version(body: VersionIdRequest, auth=Depends(require_permission("version:manage"))):
|
||||
vid = body.version_id
|
||||
v_info = version_repository.get_version_for_delete(vid)
|
||||
if not v_info:
|
||||
|
||||
@@ -17,8 +17,8 @@ from app.services.common_service import is_executable_path, policy_row_to_dict,
|
||||
from app.services.signing_service import canonical_manifest_bytes, sign_device_identity, sign_manifest, sign_policy
|
||||
|
||||
|
||||
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows")
|
||||
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64")
|
||||
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
|
||||
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64").strip() or "x64"
|
||||
SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1")
|
||||
|
||||
router = APIRouter(tags=["client-update"])
|
||||
@@ -45,6 +45,8 @@ def normalize_relative_path(raw_path: str) -> str:
|
||||
|
||||
@router.post("/api/v1/device/issue")
|
||||
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()
|
||||
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
|
||||
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")
|
||||
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:
|
||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||
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,
|
||||
"min_supported_version": settings["min_supported_version"],
|
||||
"disabled_versions": settings["disabled_versions"],
|
||||
"git_tags_enabled": settings["git_tags_enabled"],
|
||||
"message": message,
|
||||
"signature_alg": "RSA-2048-SHA256",
|
||||
"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")
|
||||
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
|
||||
# 下载链接不直接暴露 MinIO 永久地址,而是按版本和设备凭证生成短期可用的预签名 URL。
|
||||
# 这样既能让客户端直接下载大文件,又能保留服务端授权控制。
|
||||
identity = request.state.device_identity
|
||||
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
|
||||
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")
|
||||
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:
|
||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||
ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
|
||||
|
||||
@@ -17,6 +17,8 @@ def crash_health():
|
||||
|
||||
@router.post("/api/v1/crash-reports")
|
||||
async def create_crash_report(request: Request):
|
||||
# 崩溃报告接口给 SimCAE/CrashReporter 调用,接收 metadata、minidump 和可选附件。
|
||||
# 具体 token 校验、大小限制、落盘和幂等处理都集中在 crash_api_service。
|
||||
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")
|
||||
async def upload_symbols(request: Request):
|
||||
# 符号包用于后续解析 minidump 堆栈;它和普通升级文件分开存储、分开授权。
|
||||
return await crash_api_service.upload_symbols(request)
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.core.config import configured_path
|
||||
|
||||
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_INDEX_PATH = ADMIN_UI_DIST_PATH / "index.html"
|
||||
ADMIN_UI_ASSETS_PATH = ADMIN_UI_DIST_PATH / "assets"
|
||||
@@ -23,20 +22,10 @@ def mount_admin_assets(app: FastAPI):
|
||||
|
||||
|
||||
@router.get("/")
|
||||
@router.get("/admin.html")
|
||||
def admin_page():
|
||||
if ADMIN_UI_INDEX_PATH.is_file():
|
||||
return FileResponse(ADMIN_UI_INDEX_PATH, headers={"Cache-Control": "no-store"})
|
||||
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("/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"})
|
||||
if not ADMIN_UI_INDEX_PATH.is_file():
|
||||
raise HTTPException(status_code=404, detail="管理后台前端文件不存在,请先构建 admin-ui/dist")
|
||||
return FileResponse(ADMIN_UI_INDEX_PATH, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
|
||||
@@ -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
@@ -19,14 +19,18 @@ async def admin_audit_middleware(request: Request, call_next):
|
||||
finally:
|
||||
if should_audit:
|
||||
try:
|
||||
token = request.headers.get("X-Admin-Token", "")
|
||||
principal = getattr(request.state, "admin_principal", None)
|
||||
token = request.headers.get("authorization", "")
|
||||
conn = db.get_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO admin_audit_logs
|
||||
(actor_hash,action,method,path,target,result,status_code,ip,user_agent)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(actor_hash,actor_username,actor_roles,auth_type,action,method,path,target,result,status_code,ip,user_agent)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
token_digest(token)[:16] if token else "anonymous",
|
||||
principal.actor_hash if principal else (token_digest(token)[:16] if token else "anonymous"),
|
||||
principal.username if principal else "anonymous",
|
||||
",".join(principal.roles) if principal else "",
|
||||
principal.auth_type if principal else "",
|
||||
request.url.path.removeprefix("/admin/"),
|
||||
request.method,
|
||||
request.url.path,
|
||||
|
||||
+265
-6
@@ -1,20 +1,235 @@
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
|
||||
from app.core.config import ENV_FILE_PATH, settings
|
||||
from app.repositories import admin_user_repository
|
||||
|
||||
|
||||
if not settings.admin_token:
|
||||
raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
|
||||
|
||||
JWT_SECRET = os.getenv("ADMIN_JWT_SECRET", "").strip() or settings.admin_token
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ADMIN_ACCESS_TOKEN_EXPIRE_MIN", "120"))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("ADMIN_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
PASSWORD_HASHER = PasswordHasher()
|
||||
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"super_admin": ["*:*:*"],
|
||||
"release_admin": [
|
||||
"admin:access",
|
||||
"app:manage",
|
||||
"channel:manage",
|
||||
"version:manage",
|
||||
"publish:manage",
|
||||
"policy:manage",
|
||||
"device:view",
|
||||
"log:view",
|
||||
"config:view",
|
||||
],
|
||||
"license_admin": [
|
||||
"admin:access",
|
||||
"license:manage",
|
||||
"device:view",
|
||||
"config:view",
|
||||
"log:view",
|
||||
],
|
||||
"auditor": [
|
||||
"admin:access",
|
||||
"app:view",
|
||||
"version:view",
|
||||
"device:view",
|
||||
"license:view",
|
||||
"log:view",
|
||||
"crash:view",
|
||||
"config:view",
|
||||
],
|
||||
"crash_admin": [
|
||||
"admin:access",
|
||||
"crash:view",
|
||||
"log:view",
|
||||
],
|
||||
}
|
||||
|
||||
ROLE_NAMES = {
|
||||
"super_admin": "超级管理员",
|
||||
"release_admin": "发布管理员",
|
||||
"license_admin": "授权管理员",
|
||||
"auditor": "审计人员",
|
||||
"crash_admin": "崩溃报告管理员",
|
||||
}
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0)
|
||||
|
||||
|
||||
def utc_text(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_utc_text(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPrincipal:
|
||||
username: str
|
||||
display_name: str
|
||||
roles: list[str]
|
||||
permissions: list[str]
|
||||
auth_type: str
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
return permission_allowed(self.permissions, permission)
|
||||
|
||||
@property
|
||||
def actor_hash(self) -> str:
|
||||
return token_digest(self.username)[:16]
|
||||
|
||||
|
||||
def role_permissions(roles: list[str]) -> list[str]:
|
||||
permissions: list[str] = []
|
||||
for role in roles:
|
||||
permissions.extend(ROLE_PERMISSIONS.get(role, []))
|
||||
seen = set()
|
||||
result = []
|
||||
for item in permissions:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def permission_allowed(permissions: list[str], permission: str) -> bool:
|
||||
if "*:*:*" in permissions or permission in permissions:
|
||||
return True
|
||||
parts = permission.split(":")
|
||||
if len(parts) >= 2 and f"{parts[0]}:*" in permissions:
|
||||
return True
|
||||
if len(parts) == 2 and parts[1] == "view" and f"{parts[0]}:manage" in permissions:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return PASSWORD_HASHER.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return PASSWORD_HASHER.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def normalize_roles(raw_roles: str | list[str] | None) -> list[str]:
|
||||
if isinstance(raw_roles, list):
|
||||
roles = [str(item).strip() for item in raw_roles]
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(raw_roles or "[]")
|
||||
except json.JSONDecodeError:
|
||||
parsed = []
|
||||
roles = [str(item).strip() for item in parsed if str(item).strip()]
|
||||
return roles or ["super_admin"]
|
||||
|
||||
|
||||
def principal_from_user_row(row, auth_type: str = "jwt") -> AdminPrincipal:
|
||||
roles = normalize_roles(row["roles"])
|
||||
return AdminPrincipal(
|
||||
username=row["username"],
|
||||
display_name=row["display_name"] or row["username"],
|
||||
roles=roles,
|
||||
permissions=role_permissions(roles),
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
|
||||
def create_jwt_token(username: str, token_type: str, expires_delta: timedelta, roles: list[str] | None = None) -> tuple[str, str, datetime]:
|
||||
now = utc_now()
|
||||
expires_at = now + expires_delta
|
||||
token_id = secrets.token_urlsafe(24)
|
||||
payload = {
|
||||
"sub": username,
|
||||
"typ": token_type,
|
||||
"jti": token_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
if roles is not None:
|
||||
payload["roles"] = roles
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
return token, token_id, expires_at
|
||||
|
||||
|
||||
def decode_jwt_token(token: str, expected_type: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="登录已过期,请重新登录")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="无效登录凭证")
|
||||
if payload.get("typ") != expected_type:
|
||||
raise HTTPException(status_code=401, detail="登录凭证类型错误")
|
||||
return payload
|
||||
|
||||
|
||||
def token_response_for_user(row, request: Request | None = None) -> dict:
|
||||
principal = principal_from_user_row(row)
|
||||
access_token, _, access_expires = create_jwt_token(
|
||||
principal.username,
|
||||
"access",
|
||||
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
principal.roles,
|
||||
)
|
||||
refresh_token, refresh_id, refresh_expires = create_jwt_token(
|
||||
principal.username,
|
||||
"refresh",
|
||||
timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
admin_user_repository.save_refresh_token(
|
||||
refresh_id,
|
||||
principal.username,
|
||||
token_digest(refresh_token),
|
||||
utc_text(refresh_expires),
|
||||
request.headers.get("user-agent", "")[:300] if request else "",
|
||||
request.client.host if request and request.client else "",
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"avatar": "",
|
||||
"username": principal.username,
|
||||
"nickname": principal.display_name,
|
||||
"roles": principal.roles,
|
||||
"permissions": principal.permissions,
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expires": utc_text(access_expires),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def quote_env_value(value: str) -> str:
|
||||
unsafe_chars = set(" \t\n\r#\"'")
|
||||
if value and not any(ch in unsafe_chars for ch in value):
|
||||
@@ -46,10 +261,54 @@ def persist_env_value(path: Path, key: str, value: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def admin_auth(X_Admin_Token: str = Header("")):
|
||||
if not secrets.compare_digest(X_Admin_Token, settings.admin_token):
|
||||
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
|
||||
return True
|
||||
def current_admin(
|
||||
request: Request,
|
||||
Authorization: str = Header(""),
|
||||
) -> 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
|
||||
|
||||
@@ -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
|
||||
@@ -51,6 +51,17 @@ def list_licenses(app_id: str = "", include_deleted: bool = False) -> list:
|
||||
return rows
|
||||
|
||||
|
||||
def get_license_by_hash_with_usage(license_key_hash: str):
|
||||
conn = db.get_conn()
|
||||
row = conn.execute(
|
||||
"""SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices
|
||||
FROM licenses l WHERE l.license_key_hash=? AND l.status<>'deleted'""",
|
||||
(license_key_hash,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
|
||||
def set_license_status(license_id: str, status: str) -> int:
|
||||
conn = db.get_conn()
|
||||
cur = conn.execute(
|
||||
|
||||
@@ -17,6 +17,7 @@ def save_policy(
|
||||
valid_until: str,
|
||||
min_supported_version: str,
|
||||
disabled_versions_json: str,
|
||||
git_tags_enabled: bool,
|
||||
message: str,
|
||||
) -> tuple[str, int | None]:
|
||||
conn = db.get_conn()
|
||||
@@ -36,6 +37,7 @@ def save_policy(
|
||||
valid_until,
|
||||
min_supported_version,
|
||||
disabled_versions_json,
|
||||
int(bool(git_tags_enabled)),
|
||||
message,
|
||||
app_id,
|
||||
channel,
|
||||
@@ -43,13 +45,13 @@ def save_policy(
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
|
||||
min_supported_version,disabled_versions,message,app_id,channel)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
min_supported_version,disabled_versions,git_tags_enabled,message,app_id,channel)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
|
||||
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
|
||||
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refreshToken: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: str = ""
|
||||
roles: list[str] = ["auditor"]
|
||||
|
||||
|
||||
class AdminUserUpdateRequest(BaseModel):
|
||||
username: str
|
||||
display_name: str = ""
|
||||
roles: list[str]
|
||||
|
||||
|
||||
class AdminUserStatusRequest(BaseModel):
|
||||
username: str
|
||||
status: str
|
||||
|
||||
|
||||
class AdminUserResetPasswordRequest(BaseModel):
|
||||
username: str
|
||||
new_password: str
|
||||
@@ -12,4 +12,5 @@ class ClientConfigGenerateRequest(BaseModel):
|
||||
client_protocol: int = 3
|
||||
license_key: str = ""
|
||||
api_base_url: str = ""
|
||||
install_root: str = ""
|
||||
main_executable: str = ""
|
||||
|
||||
@@ -39,6 +39,11 @@ class DownloadReportRequest(BaseModel):
|
||||
files: list[dict]
|
||||
|
||||
|
||||
class GitTagsRequest(BaseModel):
|
||||
app_id: str
|
||||
channel: str
|
||||
|
||||
|
||||
class DeviceIssueRequest(BaseModel):
|
||||
app_id: str
|
||||
channel: str
|
||||
|
||||
@@ -10,4 +10,5 @@ class PolicySaveRequest(BaseModel):
|
||||
valid_until: str = ""
|
||||
min_supported_version: str = ""
|
||||
disabled_versions: list[str] = Field(default_factory=list)
|
||||
git_tags_enabled: bool = False
|
||||
message: str = ""
|
||||
|
||||
@@ -7,9 +7,30 @@ from fastapi import HTTPException
|
||||
|
||||
PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
|
||||
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
|
||||
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
|
||||
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
|
||||
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
|
||||
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
|
||||
TARGET_ARCH = os.getenv("TARGET_ARCH", "x64").strip() or "x64"
|
||||
|
||||
|
||||
def platform_executable_path(value: str, fallback_base_name: str) -> str:
|
||||
path = str(value or "").strip().replace(chr(92), "/").strip("/")
|
||||
if not path:
|
||||
path = fallback_base_name
|
||||
parts = path.split("/")
|
||||
leaf = parts[-1]
|
||||
if TARGET_PLATFORM == "windows":
|
||||
if "." not in leaf:
|
||||
leaf += ".exe"
|
||||
elif TARGET_PLATFORM == "linux":
|
||||
if leaf.casefold().endswith(".exe"):
|
||||
leaf = leaf[:-4]
|
||||
parts[-1] = leaf
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
RELEASE_MAIN_EXECUTABLE = platform_executable_path(
|
||||
os.getenv("RELEASE_MAIN_EXECUTABLE", ""),
|
||||
"bin/SimCAE",
|
||||
)
|
||||
PUBLISH_MAX_REQUEST_BYTES = int(os.getenv("PUBLISH_MAX_REQUEST_MB", "4096")) * 1024 * 1024
|
||||
PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
|
||||
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
|
||||
@@ -31,27 +52,139 @@ def default_client_main_executable() -> str:
|
||||
path = RELEASE_MAIN_EXECUTABLE.strip().replace(chr(92), "/").strip("/")
|
||||
if path.casefold().startswith("bin/"):
|
||||
path = path[4:]
|
||||
return path or "SimCAE.exe"
|
||||
return platform_executable_path(path, "SimCAE")
|
||||
|
||||
|
||||
def default_client_config_values(request: Request) -> dict:
|
||||
return {
|
||||
"api_base_url": public_api_base_url(request),
|
||||
"client_token": VALID_CLIENT_TOKEN or "",
|
||||
"launch_token": os.getenv("CLIENT_LAUNCH_TOKEN", "SimCAE_Launch_Token_2026_ChangeMe_32Bytes"),
|
||||
"request_timeout_ms": os.getenv("CLIENT_REQUEST_TIMEOUT_MS", "5000"),
|
||||
"temp_folder": os.getenv("CLIENT_TEMP_FOLDER", "update_temp"),
|
||||
"install_root": os.getenv("CLIENT_INSTALL_ROOT", ".."),
|
||||
"main_executable": os.getenv("CLIENT_MAIN_EXECUTABLE", default_client_main_executable()),
|
||||
"launcher_executable": os.getenv("CLIENT_LAUNCHER_EXECUTABLE", "Launcher.exe"),
|
||||
"updater_executable": os.getenv("CLIENT_UPDATER_EXECUTABLE", "Updater.exe"),
|
||||
"bootstrap_executable": os.getenv("CLIENT_BOOTSTRAP_EXECUTABLE", "Bootstrap.exe"),
|
||||
"main_executable": platform_executable_path(
|
||||
os.getenv("CLIENT_MAIN_EXECUTABLE", ""),
|
||||
default_client_main_executable(),
|
||||
),
|
||||
"launcher_executable": platform_executable_path(os.getenv("CLIENT_LAUNCHER_EXECUTABLE", ""), "Launcher"),
|
||||
"updater_executable": platform_executable_path(os.getenv("CLIENT_UPDATER_EXECUTABLE", ""), "Updater"),
|
||||
"bootstrap_executable": platform_executable_path(os.getenv("CLIENT_BOOTSTRAP_EXECUTABLE", ""), "Bootstrap"),
|
||||
"health_check_timeout_ms": os.getenv("CLIENT_HEALTH_CHECK_TIMEOUT_MS", "15000"),
|
||||
"platform": TARGET_PLATFORM,
|
||||
"arch": TARGET_ARCH,
|
||||
}
|
||||
|
||||
|
||||
def crash_report_test_values(api_base_url: str) -> dict:
|
||||
base_url = api_base_url.rstrip("/")
|
||||
crash_admin_token = os.getenv("CRASH_ADMIN_TOKEN", "").strip()
|
||||
admin_token = os.getenv("ADMIN_TOKEN", "").strip()
|
||||
crash_report_token = (
|
||||
os.getenv("CRASH_REPORT_TOKEN")
|
||||
or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN")
|
||||
or os.getenv("CLIENT_API_TOKEN")
|
||||
or ""
|
||||
)
|
||||
crash_symbol_token = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN") or ""
|
||||
effective_admin_token = crash_admin_token or admin_token
|
||||
effective_admin_token_source = "CRASH_ADMIN_TOKEN" if crash_admin_token else "ADMIN_TOKEN"
|
||||
return {
|
||||
"说明": "仅用于后台管理员/测试人员联调崩溃报告接口,不要复制进客户端 app_config.json。",
|
||||
"server_base_url": base_url,
|
||||
"tokens": {
|
||||
"CRASH_REPORT_TOKEN": {
|
||||
"用途": "上传崩溃报告 /api/v1/crash-reports",
|
||||
"token": crash_report_token,
|
||||
},
|
||||
"CRASH_SYMBOL_TOKEN": {
|
||||
"用途": "上传符号包 /api/v1/symbols",
|
||||
"token": crash_symbol_token,
|
||||
},
|
||||
"CRASH_ADMIN_TOKEN": {
|
||||
"用途": "查询和下载崩溃报告",
|
||||
"token": crash_admin_token,
|
||||
"说明": "如果为空,查询/下载接口会使用 ADMIN_TOKEN。",
|
||||
},
|
||||
"ADMIN_TOKEN": {
|
||||
"用途": "服务端兜底令牌;当 CRASH_ADMIN_TOKEN 为空时也用于查询/下载崩溃报告",
|
||||
"token": admin_token,
|
||||
},
|
||||
},
|
||||
"upload_crash_report": {
|
||||
"url": f"{base_url}/api/v1/crash-reports",
|
||||
"authorization_header": "Authorization: Bearer <CRASH_REPORT_TOKEN>",
|
||||
"token_env": "CRASH_REPORT_TOKEN",
|
||||
"token": crash_report_token,
|
||||
"idempotency_key_rule": "Idempotency-Key 必须等于 metadata.clientReportId",
|
||||
},
|
||||
"upload_symbols": {
|
||||
"url": f"{base_url}/api/v1/symbols",
|
||||
"authorization_header": "Authorization: Bearer <CRASH_SYMBOL_TOKEN>",
|
||||
"token_env": "CRASH_SYMBOL_TOKEN",
|
||||
"token": crash_symbol_token,
|
||||
},
|
||||
"admin_query_and_download": {
|
||||
"detail_url_template": f"{base_url}/api/v1/crash-reports/{{report_id}}",
|
||||
"file_url_template": f"{base_url}/api/v1/crash-reports/{{report_id}}/files/{{file_name}}",
|
||||
"authorization_header": f"Authorization: Bearer <{effective_admin_token_source}>",
|
||||
"token_env": effective_admin_token_source,
|
||||
"token": effective_admin_token,
|
||||
"file_name_examples": ["metadata.json", "crash.dmp", "attachments.zip", "server.json"],
|
||||
},
|
||||
"limits": {
|
||||
"CRASH_SERVICE_VERSION": os.getenv("CRASH_SERVICE_VERSION", "1.0.0"),
|
||||
"CRASH_METADATA_MAX_KB": os.getenv("CRASH_METADATA_MAX_KB", "256"),
|
||||
"CRASH_MINIDUMP_MAX_MB": os.getenv("CRASH_MINIDUMP_MAX_MB", "128"),
|
||||
"CRASH_ATTACHMENTS_MAX_MB": os.getenv("CRASH_ATTACHMENTS_MAX_MB", "64"),
|
||||
"CRASH_REQUEST_MAX_MB": os.getenv("CRASH_REQUEST_MAX_MB", "200"),
|
||||
"CRASH_SYMBOLS_MAX_MB": os.getenv("CRASH_SYMBOLS_MAX_MB", "512"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def crash_report_test_text(api_base_url: str) -> str:
|
||||
values = crash_report_test_values(api_base_url)
|
||||
tokens = values["tokens"]
|
||||
lines = [
|
||||
"# 说明:下面内容仅用于后台管理员/测试人员联调崩溃报告接口。",
|
||||
"# 注意:不要复制进客户端 app_config.json,也不要放进客户端配置文件。",
|
||||
"",
|
||||
"# 服务端基础地址。curl 示例里的 SERVER 就填这个值。",
|
||||
f"SERVER_BASE_URL={values['server_base_url']}",
|
||||
"",
|
||||
"# 上传崩溃报告使用的 Token。",
|
||||
"# 用途:POST /api/v1/crash-reports",
|
||||
"# 请求头写法:Authorization: Bearer <CRASH_REPORT_TOKEN>",
|
||||
f"CRASH_REPORT_TOKEN={tokens['CRASH_REPORT_TOKEN']['token']}",
|
||||
f"CRASH_REPORT_URL={values['upload_crash_report']['url']}",
|
||||
"",
|
||||
"# 上传符号包使用的 Token。",
|
||||
"# 用途:POST /api/v1/symbols",
|
||||
"# 请求头写法:Authorization: Bearer <CRASH_SYMBOL_TOKEN>",
|
||||
f"CRASH_SYMBOL_TOKEN={tokens['CRASH_SYMBOL_TOKEN']['token']}",
|
||||
f"CRASH_SYMBOL_URL={values['upload_symbols']['url']}",
|
||||
"",
|
||||
"# 查询和下载崩溃报告使用的 Token。",
|
||||
"# 如果 CRASH_ADMIN_TOKEN 为空,就使用 ADMIN_TOKEN。",
|
||||
"# 请求头写法:Authorization: Bearer <CRASH_ADMIN_TOKEN 或 ADMIN_TOKEN>",
|
||||
f"CRASH_ADMIN_TOKEN={tokens['CRASH_ADMIN_TOKEN']['token']}",
|
||||
f"ADMIN_TOKEN={tokens['ADMIN_TOKEN']['token']}",
|
||||
f"CRASH_REPORT_DETAIL_URL_TEMPLATE={values['admin_query_and_download']['detail_url_template']}",
|
||||
f"CRASH_REPORT_FILE_URL_TEMPLATE={values['admin_query_and_download']['file_url_template']}",
|
||||
"",
|
||||
"# 可下载文件名。",
|
||||
"CRASH_REPORT_FILE_NAMES=metadata.json,crash.dmp,attachments.zip,server.json",
|
||||
"",
|
||||
"# 幂等规则:Idempotency-Key 必须等于 metadata.json 里的 clientReportId。",
|
||||
"CRASH_IDEMPOTENCY_KEY_RULE=Idempotency-Key must equal metadata.clientReportId",
|
||||
"",
|
||||
"# 崩溃报告服务版本和大小限制。",
|
||||
]
|
||||
for key, value in values["limits"].items():
|
||||
lines.append(f"{key}={value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def normalize_executable_name(value: str) -> str:
|
||||
normalized = str(value or "").strip().replace(chr(92), "/").strip("/")
|
||||
if not normalized:
|
||||
|
||||
@@ -13,6 +13,7 @@ def policy_row_to_dict(row) -> dict:
|
||||
"valid_until": "2099-12-31T23:59:59Z",
|
||||
"min_supported_version": "",
|
||||
"disabled_versions": [],
|
||||
"git_tags_enabled": False,
|
||||
"message": "",
|
||||
}
|
||||
try:
|
||||
@@ -27,6 +28,7 @@ def policy_row_to_dict(row) -> dict:
|
||||
"valid_until": row["valid_until"],
|
||||
"min_supported_version": row["min_supported_version"] or "",
|
||||
"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 "",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,8 @@ from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
|
||||
|
||||
|
||||
def license_key_encryption_material() -> bytes:
|
||||
# 数据库只存 License Key 的密文和 hash。
|
||||
# hash 用于客户端提交 License 时快速匹配;密文用于后台列表需要再次展示原始 Key 的场景。
|
||||
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
|
||||
if configured_secret:
|
||||
return configured_secret.encode("utf-8")
|
||||
|
||||
+121
-13
@@ -18,7 +18,15 @@ from app.repositories import publish_repository
|
||||
from app.services.common_service import require_channel
|
||||
|
||||
|
||||
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
|
||||
TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
|
||||
DEFAULT_RELEASE_MAIN_EXECUTABLE = "bin/SimCAE" if TARGET_PLATFORM == "linux" else "bin/SimCAE.exe"
|
||||
RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", DEFAULT_RELEASE_MAIN_EXECUTABLE).strip().replace(chr(92), "/").strip("/")
|
||||
if TARGET_PLATFORM == "linux" and RELEASE_MAIN_EXECUTABLE.casefold().endswith(".exe"):
|
||||
RELEASE_MAIN_EXECUTABLE = RELEASE_MAIN_EXECUTABLE[:-4]
|
||||
elif TARGET_PLATFORM == "windows":
|
||||
leaf = RELEASE_MAIN_EXECUTABLE.rsplit("/", 1)[-1]
|
||||
if "." not in leaf:
|
||||
RELEASE_MAIN_EXECUTABLE += ".exe"
|
||||
UPLOAD_SPOOL_DIR = configured_path("UPLOAD_SPOOL_DIR", "upload_spool")
|
||||
UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tempfile.tempdir = str(UPLOAD_SPOOL_DIR)
|
||||
@@ -29,6 +37,41 @@ PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000"))
|
||||
PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100)))
|
||||
|
||||
|
||||
def normalize_install_root(raw_value: str) -> str:
|
||||
value = str(raw_value or os.getenv("CLIENT_INSTALL_ROOT", "..")).strip().replace(chr(92), "/")
|
||||
if value in ("", "."):
|
||||
return "."
|
||||
if value == "..":
|
||||
return ".."
|
||||
raise HTTPException(status_code=400, detail="install_root 目前仅支持 . 或 ..")
|
||||
|
||||
|
||||
def normalize_publish_executable(raw_value: str) -> str:
|
||||
value = str(raw_value or "").strip().replace(chr(92), "/").strip("/")
|
||||
if not value:
|
||||
value = RELEASE_MAIN_EXECUTABLE.rsplit("/", 1)[-1]
|
||||
if TARGET_PLATFORM == "linux" and value.casefold().endswith(".exe"):
|
||||
value = value[:-4]
|
||||
elif TARGET_PLATFORM == "windows":
|
||||
parts = value.split("/")
|
||||
leaf = parts[-1]
|
||||
if "." not in leaf:
|
||||
leaf += ".exe"
|
||||
parts[-1] = leaf
|
||||
value = "/".join(parts)
|
||||
return normalize_relative_path(value)
|
||||
|
||||
|
||||
def release_main_executable_for_install(install_root: str, main_executable: str) -> str:
|
||||
normalized_install_root = normalize_install_root(install_root)
|
||||
normalized_main = normalize_publish_executable(main_executable)
|
||||
if normalized_install_root == ".":
|
||||
return normalized_main
|
||||
if normalized_main.casefold().startswith("bin/"):
|
||||
return normalized_main
|
||||
return normalize_relative_path(f"bin/{normalized_main}")
|
||||
|
||||
|
||||
def normalize_relative_path(raw_path: str) -> str:
|
||||
if not isinstance(raw_path, str):
|
||||
raise HTTPException(status_code=400, detail="文件相对路径无效")
|
||||
@@ -65,6 +108,8 @@ def should_skip_release_directory(part: str) -> bool:
|
||||
|
||||
def should_skip_release_file(relative_path: str) -> bool:
|
||||
folded = relative_path.casefold()
|
||||
# 发布包里不能带客户端本机运行态文件。
|
||||
# 这些文件包含 License、设备身份、策略缓存等机器相关数据,应由客户端运行时生成或服务端重新签发。
|
||||
runtime_protected_files = {
|
||||
"client.ini",
|
||||
"bootstrap.exe",
|
||||
@@ -120,6 +165,8 @@ def supported_archive_kind(filename: str) -> str:
|
||||
|
||||
|
||||
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
|
||||
# 解压压缩包时必须防止 Zip Slip / 路径穿越。
|
||||
# 任何 ../ 或绝对路径都不能写出临时解压目录。
|
||||
target = (root / relative_path).resolve()
|
||||
root_resolved = root.resolve()
|
||||
if target != root_resolved and root_resolved not in target.parents:
|
||||
@@ -248,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")
|
||||
|
||||
|
||||
def release_items_from_extracted_dir(extract_dir: Path):
|
||||
def release_items_from_extracted_dir(extract_dir: Path, required_main_executable: str):
|
||||
items = []
|
||||
for path in extract_dir.rglob("*"):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
@@ -257,13 +304,15 @@ def release_items_from_extracted_dir(extract_dir: Path):
|
||||
if should_skip_release_path(rel_path):
|
||||
continue
|
||||
items.append({"path": rel_path, "local_path": path})
|
||||
return normalize_release_item_roots(items)
|
||||
return normalize_release_item_roots(items, required_main_executable)
|
||||
|
||||
|
||||
def normalize_release_item_roots(items: list[dict]):
|
||||
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
|
||||
# 有些压缩包会多包一层顶级目录,例如 SimCAE-1.0.0/bin/SimCAE.exe。
|
||||
# 如果所有文件都在同一个顶级目录下,并且能找到主程序,就自动剥掉这一层,降低发布者操作成本。
|
||||
if not items:
|
||||
return items
|
||||
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
|
||||
required_main_key = required_main_executable.casefold()
|
||||
lower_paths = [item["path"].casefold() for item in items]
|
||||
if required_main_key in lower_paths:
|
||||
return items
|
||||
@@ -290,7 +339,7 @@ def normalize_release_item_roots(items: list[dict]):
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_release_items(items: list[dict]):
|
||||
def validate_release_items(items: list[dict], required_main_executable: str):
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
|
||||
if len(items) > PUBLISH_MAX_FILES:
|
||||
@@ -314,15 +363,15 @@ def validate_release_items(items: list[dict]):
|
||||
if not filtered:
|
||||
raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件")
|
||||
|
||||
required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
|
||||
required_main_key = required_main_executable.casefold()
|
||||
if required_main_key not in seen_paths:
|
||||
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
|
||||
if nested_main:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{RELEASE_MAIN_EXECUTABLE} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
|
||||
detail=f"{required_main_executable} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {RELEASE_MAIN_EXECUTABLE}")
|
||||
raise HTTPException(status_code=400, detail=f"发布目录中缺少 {required_main_executable}")
|
||||
nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None)
|
||||
if nested_main:
|
||||
raise HTTPException(
|
||||
@@ -374,6 +423,7 @@ async def close_upload_safely(upload):
|
||||
|
||||
def ensure_publish_storage_space(next_file_size: int):
|
||||
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
|
||||
if available_bytes < required_bytes:
|
||||
raise HTTPException(
|
||||
@@ -388,6 +438,8 @@ def ensure_publish_storage_space(next_file_size: int):
|
||||
|
||||
|
||||
async def collect_publish_items(request: Request):
|
||||
# 发布入口支持两种方式:浏览器选择 Release 目录,或上传一个压缩发布包。
|
||||
# 这里先做请求大小、临时目录空间和 MinIO 存储空间检查,避免大包把服务器拖死。
|
||||
content_length_header = request.headers.get("content-length")
|
||||
try:
|
||||
content_length = int(content_length_header or 0)
|
||||
@@ -453,6 +505,9 @@ async def collect_publish_items(request: Request):
|
||||
app_id = form.get("app_id")
|
||||
channel = form.get("channel") or ""
|
||||
version = form.get("version")
|
||||
install_root = normalize_install_root(str(form.get("install_root") or ""))
|
||||
main_executable = str(form.get("main_executable") or "")
|
||||
required_main_executable = release_main_executable_for_install(install_root, main_executable)
|
||||
try:
|
||||
client_protocol = int(form.get("client_protocol") or 2)
|
||||
except (TypeError, ValueError):
|
||||
@@ -518,8 +573,8 @@ async def collect_publish_items(request: Request):
|
||||
extract_dir = archive_temp_dir / "extracted"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
extract_release_archive(archive_path, archive_name, extract_dir)
|
||||
raw_items = release_items_from_extracted_dir(extract_dir)
|
||||
upload_items = validate_release_items(raw_items)
|
||||
raw_items = release_items_from_extracted_dir(extract_dir, required_main_executable)
|
||||
upload_items = validate_release_items(raw_items, required_main_executable)
|
||||
extracted_total = sum(item["local_path"].stat().st_size for item in upload_items)
|
||||
check_extracted_publish_limits(extracted_total, len(upload_items))
|
||||
else:
|
||||
@@ -532,7 +587,7 @@ async def collect_publish_items(request: Request):
|
||||
for index, file in enumerate(files):
|
||||
raw_path = relative_paths[index] if relative_paths else file.filename
|
||||
raw_items.append({"upload": file, "path": str(raw_path)})
|
||||
upload_items = validate_release_items(raw_items)
|
||||
upload_items = validate_release_items(raw_items, required_main_executable)
|
||||
except Exception:
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
@@ -544,9 +599,12 @@ async def collect_publish_items(request: Request):
|
||||
"channel": str(channel),
|
||||
"version": str(version),
|
||||
"client_protocol": client_protocol,
|
||||
"install_root": install_root,
|
||||
"required_main_executable": required_main_executable,
|
||||
"upload_items": upload_items,
|
||||
"archive_upload": archive_upload,
|
||||
"archive_temp_dir": archive_temp_dir,
|
||||
"job_temp_dir": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -562,11 +620,55 @@ def cleanup_path(path: Path | None):
|
||||
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)
|
||||
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. 上传文件到 MinIO;4. 写入文件 hash/size。
|
||||
# 任何一步失败都会回滚数据库,并删除已经上传的对象前缀,避免出现“半个版本”。
|
||||
upload_items = payload["upload_items"]
|
||||
archive_upload = payload["archive_upload"]
|
||||
archive_temp_dir = payload["archive_temp_dir"]
|
||||
job_temp_dir = payload.get("job_temp_dir")
|
||||
app_id = payload["app_id"]
|
||||
channel = payload["channel"]
|
||||
version = payload["version"]
|
||||
@@ -641,3 +743,9 @@ async def publish_version(request: Request):
|
||||
if archive_upload is not None:
|
||||
await close_upload_safely(archive_upload)
|
||||
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)
|
||||
|
||||
@@ -21,11 +21,15 @@ def load_manifest_private_key():
|
||||
|
||||
|
||||
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
|
||||
# 签名之前必须先规范化 JSON:固定字段顺序、去掉空格、排除 signature 字段。
|
||||
# 否则同一份 Manifest 在不同环境下序列化结果不同,会导致客户端验签失败。
|
||||
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")
|
||||
|
||||
|
||||
def sign_manifest(manifest_obj: dict) -> str:
|
||||
# 服务端只保存私钥,客户端只分发公钥。
|
||||
# 客户端能验证 Manifest 确实由服务端签发,但无法伪造新的签名。
|
||||
json_text = canonical_manifest_bytes(manifest_obj)
|
||||
private_key = load_manifest_private_key()
|
||||
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||
|
||||
api:
|
||||
# 离线部署包使用已经 docker load 导入的镜像,不在目标服务器重新 build。
|
||||
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
|
||||
restart: unless-stopped
|
||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||
@@ -45,7 +46,6 @@ services:
|
||||
ENV_FILE_PATH: /app/.env
|
||||
DB_FILE: /data/mini.db
|
||||
SQL_FILE: /app/tables.sql
|
||||
ADMIN_HTML_PATH: /app/legacy/admin.html
|
||||
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||
MINIO_DATA_DIR: /minio_data
|
||||
@@ -58,6 +58,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# runtime 保存 SQLite、上传缓存和崩溃报告等运行数据;keys 只读挂载,避免私钥被容器误改。
|
||||
- ./.env:/app/.env
|
||||
- ./runtime:/data
|
||||
- ./minio_data:/minio_data:ro
|
||||
|
||||
+4
-3
@@ -34,9 +34,10 @@ services:
|
||||
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||
|
||||
api:
|
||||
# 开发/源码部署使用 build,从当前源码构建镜像;正式离线交付通常使用 docker-compose.image.yml 生成的包。
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: server/Dockerfile
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||
env_file:
|
||||
@@ -47,7 +48,6 @@ services:
|
||||
ENV_FILE_PATH: /app/.env
|
||||
DB_FILE: /data/mini.db
|
||||
SQL_FILE: /app/tables.sql
|
||||
ADMIN_HTML_PATH: /app/legacy/admin.html
|
||||
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||
MINIO_DATA_DIR: /minio_data
|
||||
@@ -60,6 +60,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-8000}:8000"
|
||||
volumes:
|
||||
# 数据卷和密钥挂载在容器外,重建镜像或升级容器时不会丢失历史版本和授权数据。
|
||||
- ./.env:/app/.env
|
||||
- ./runtime:/data
|
||||
- ./minio_data:/minio_data:ro
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import minio_tool
|
||||
from app.api.router import include_api_routes
|
||||
from app.core.audit import admin_audit_middleware
|
||||
from app.core.config import configured_path, env_bool, settings
|
||||
from app.core.security import ensure_default_admin_user
|
||||
from app.services.crash_api_service import ensure_storage_dirs
|
||||
from app.services.device_credential_service import verify_device_credential
|
||||
from app.services.publish_service import UPLOAD_SPOOL_DIR
|
||||
@@ -54,6 +55,16 @@ async def lifespan(app: FastAPI):
|
||||
license_columns = {row[1] for row in cur.execute("PRAGMA table_info(licenses)").fetchall()}
|
||||
if "license_key_cipher" not in license_columns:
|
||||
cur.execute("ALTER TABLE licenses ADD COLUMN license_key_cipher TEXT NOT NULL DEFAULT ''")
|
||||
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():
|
||||
if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone():
|
||||
cur.executemany(
|
||||
@@ -72,6 +83,7 @@ async def lifespan(app: FastAPI):
|
||||
print("===== 数据库初始化完成 =====")
|
||||
|
||||
ensure_storage_dirs()
|
||||
ensure_default_admin_user()
|
||||
start_minio_if_needed()
|
||||
|
||||
yield
|
||||
@@ -175,6 +187,8 @@ async def auth_middleware(request: Request, call_next):
|
||||
"/platform-config.json",
|
||||
"/logo.svg",
|
||||
"/health",
|
||||
"/login",
|
||||
"/refresh-token",
|
||||
"/api/v1/health",
|
||||
"/api/v1/crash-reports",
|
||||
"/api/v1/symbols",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
|
||||
pytest>=8,<9
|
||||
@@ -1,6 +1,8 @@
|
||||
cryptography==49.0.0
|
||||
fastapi==0.138.0
|
||||
minio==7.2.20
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT==2.10.1
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
uvicorn==0.49.0
|
||||
|
||||
@@ -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"
|
||||
|
||||
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
|
||||
# SimCAE 服务端 Docker 部署包说明
|
||||
# SimCAE 服务端 Docker 打包和部署指南
|
||||
|
||||
本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。
|
||||
|
||||
@@ -118,6 +118,17 @@ cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
|
||||
|
||||
如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。
|
||||
|
||||
## 先看这里:你要做哪件事
|
||||
|
||||
| 你的目标 | 直接看哪一节 |
|
||||
| --- | --- |
|
||||
| 从源码重新生成离线部署包 | 一、维护者:生成 Docker 离线部署包 |
|
||||
| 了解交付包里有哪些文件 | 二、这个包里面有什么 |
|
||||
| 在服务器上部署并启动 | 三、使用者:最小部署流程 |
|
||||
| 修改 `.env` 配置 | 四、.env 配置怎么填 |
|
||||
| 发布 Windows/Linux 客户端版本 | 六、发布新版本 |
|
||||
| 查看日志、重启、备份、升级 | 七、常用命令 |
|
||||
|
||||
## 一、维护者:生成 Docker 离线部署包
|
||||
|
||||
在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。
|
||||
@@ -251,7 +262,7 @@ bash ./load-images.sh
|
||||
nano .env
|
||||
```
|
||||
|
||||
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址:
|
||||
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成客户端能访问到的服务器地址:
|
||||
|
||||
```text
|
||||
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||
@@ -299,13 +310,16 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
|
||||
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
|
||||
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
|
||||
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
|
||||
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成客户端 `app_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
|
||||
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 |
|
||||
| `PUBLIC_API_BASE_URL` | 可不填 | 可以 | 管理页生成 qrc 服务端配置 `server_config.json` 时使用的后端 API 地址。不填时自动使用当前访问后台的地址;如果经过域名、反向代理或端口映射,建议填成客户端实际能访问的地址,例如 `http://服务器IP:8000`。 |
|
||||
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。Windows 示例:`bin/SimCAE.exe`;Linux 示例:`bin/SimCAE`。 |
|
||||
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
|
||||
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 必填 | 可以 | 管理后台初始用户名和密码。首次启动且数据库里没有管理员用户时,会自动创建这个账号。网页登录时输入这组账号密码。 |
|
||||
| `ADMIN_JWT_SECRET` | 必填 | 可以 | 管理后台 JWT 签名密钥。正式部署建议改成随机长字符串,并长期保持不变;改掉后旧登录 token 会失效。 |
|
||||
| `ADMIN_TOKEN` | 必填 | 可以 | 服务端兜底令牌。管理后台不再用它直接登录;它仍用于 `ADMIN_JWT_SECRET` 未设置时的默认签名密钥,以及 `CRASH_ADMIN_TOKEN` 为空时的崩溃报告管理兜底令牌。 |
|
||||
| `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_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
|
||||
|
||||
@@ -317,7 +331,7 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
|
||||
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
|
||||
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 |
|
||||
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 |
|
||||
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | Windows x64 填 `windows` / `x64`;Linux x64 填 `linux` / `x64`。 |
|
||||
| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
|
||||
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
|
||||
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
|
||||
@@ -362,15 +376,15 @@ MinIO 控制台: http://你的服务器IP:9001/
|
||||
3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
|
||||
4. 打开“客户端配置生成”
|
||||
5. 点击“生成配套配置”
|
||||
6. 点击“复制配置”
|
||||
7. 粘贴到客户端 bin/config/app_config.json
|
||||
6. 点击“复制配置”,粘贴到客户端 `bin/config/app_config.json`
|
||||
7. 点击“复制 qrc 配置”,粘贴到客户端源码 `config/server_config.json`
|
||||
8. 重新编译 Launcher / Updater / Bootstrap,让 `api_base_url` 通过 qrc 编进程序
|
||||
```
|
||||
|
||||
客户端 `config/app_config.json` 至少要和服务端保持这两个值一致:
|
||||
客户端 `config/app_config.json` 至少要和服务端保持这个值一致:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_base_url": "http://你的服务器IP:8000",
|
||||
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
|
||||
}
|
||||
```
|
||||
@@ -387,23 +401,36 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||
"client_token": "SimCAEClientToken2026"
|
||||
```
|
||||
|
||||
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
|
||||
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。它现在位于客户端源码 `config/server_config.json`,并通过 qrc 编译进程序,不再写入客户端 `app_config.json` 或注册表。
|
||||
|
||||
## 六、发布新版本
|
||||
|
||||
进入后台后,“发布新版本”支持两种方式:
|
||||
|
||||
```text
|
||||
1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe
|
||||
1. 选择软件发布根目录,例如 SimCAE 目录。Windows 目录内应包含 bin/SimCAE.exe;Linux 目录内应包含 bin/SimCAE
|
||||
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
||||
```
|
||||
|
||||
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是:
|
||||
点击“发布版本”后,浏览器会先把文件上传到服务端。上传完成后,服务端会创建后台发布任务,继续执行解压、Manifest 校验、数据库写入和 MinIO 上传;管理页面会轮询并显示任务状态。这样可以减少大版本发布时长时间占用同一个 HTTP 请求的问题。
|
||||
|
||||
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
|
||||
|
||||
```text
|
||||
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||
```
|
||||
|
||||
Linux 客户端部署时建议改成:
|
||||
|
||||
```text
|
||||
TARGET_PLATFORM=linux
|
||||
RELEASE_MAIN_EXECUTABLE=bin/SimCAE
|
||||
CLIENT_MAIN_EXECUTABLE=SimCAE
|
||||
CLIENT_LAUNCHER_EXECUTABLE=Launcher
|
||||
CLIENT_UPDATER_EXECUTABLE=Updater
|
||||
CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap
|
||||
```
|
||||
|
||||
`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
|
||||
|
||||
## 七、常用命令
|
||||
@@ -520,13 +547,13 @@ CLIENT_API_TOKEN=...
|
||||
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||
```
|
||||
|
||||
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
|
||||
这个地址必须从客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
|
||||
|
||||
## 十、安全提醒
|
||||
|
||||
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
|
||||
|
||||
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
|
||||
默认用户名、默认密码、默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
|
||||
EOF_README
|
||||
|
||||
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
|
||||
|
||||
+30
-1
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS version_policies (
|
||||
valid_until TEXT NOT NULL,
|
||||
min_supported_version TEXT NOT NULL DEFAULT '',
|
||||
disabled_versions TEXT NOT NULL DEFAULT '[]',
|
||||
git_tags_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_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. 管理后台写操作审计
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, actor_username TEXT NOT NULL DEFAULT '',
|
||||
actor_roles TEXT NOT NULL DEFAULT '', auth_type TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
|
||||
method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at);
|
||||
|
||||
-- 11.1 管理后台用户、角色与刷新令牌
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
roles TEXT NOT NULL DEFAULT '["super_admin"]',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
last_login_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_users_status ON admin_users(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_refresh_tokens_username ON admin_refresh_tokens(username,expires_at);
|
||||
|
||||
|
||||
-- 12. SimCAE 崩溃报告原始数据索引
|
||||
CREATE TABLE IF NOT EXISTS crash_reports (
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user