diff --git a/.dockerignore b/.dockerignore index 1c52f22..6309281 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,11 @@ !db.py !minio_tool.py !tables.sql -!admin.html +!app/ +!app/** +!legacy/ +!legacy/** +!admin-ui/ +!admin-ui/** +admin-ui/node_modules/ +admin-ui/dist/ diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..5accb67 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,99 @@ +# Docker 镜像名称。通常不用改;重新打镜像并改版本号时才需要同步修改。 +SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0 + +# 服务端端口。浏览器访问 http://服务器IP:8000/。 +SERVER_PORT=8000 + +# 如果后台经过域名、反向代理或端口映射访问,可以在这里固定客户端 API 地址。 +# 留空时,管理页会按当前访问后台的地址自动生成 api_base_url。 +PUBLIC_API_BASE_URL= + +# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。 +APP_UID=1000 +APP_GID=1000 + +# 管理后台标题。可不填或不改。 +SERVICE_TITLE=SimCAE Update Service + +# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。 +CORS_ALLOW_ORIGINS=* + +# 发布包目标平台。当前客户端是 Windows x64,通常不用改。 +TARGET_PLATFORM=windows +TARGET_ARCH=x64 + +# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。 +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe + +# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。 +SIGNING_KEY_ID=manifest-key-v1 + +# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。 +# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。 +PUBLISH_MAX_REQUEST_MB=4096 +PUBLISH_MAX_FILES=20000 +# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。 +PUBLISH_MAX_FIELDS=20100 +# 上传时额外保留的磁盘空间,单位 MB。通常不用改。 +UPLOAD_SPACE_RESERVE_MB=256 + +# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改。 +# 管理页“客户端配置生成”会自动把这个值写入 app_config.json 的 client_token。 +CLIENT_API_TOKEN=SimCAEClientToken2026 + +# 管理后台令牌。已给默认值,可直接试跑;网页登录时在管理员令牌输入这个值,正式部署建议修改。 +ADMIN_TOKEN=SimCAEAdminToken2026 + +# License Key 加密保存密钥。后台授权列表需要用它解密显示 License Key。 +# 已给默认值,可直接试跑;正式部署建议修改,并且部署后长期保持不变。 +# 如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 +LICENSE_KEY_ENCRYPTION_SECRET=SimCAE_License_Key_Encryption_2026_ChangeMe + +# 管理页生成 app_config.json 时使用的客户端默认值。普通 SimCAE 部署通常不用改。 +CLIENT_LAUNCH_TOKEN=SimCAE_Launch_Token_2026_ChangeMe_32Bytes +CLIENT_REQUEST_TIMEOUT_MS=5000 +CLIENT_TEMP_FOLDER=update_temp +CLIENT_INSTALL_ROOT=.. +CLIENT_MAIN_EXECUTABLE=SimCAE.exe +CLIENT_LAUNCHER_EXECUTABLE=Launcher.exe +CLIENT_UPDATER_EXECUTABLE=Updater.exe +CLIENT_BOOTSTRAP_EXECUTABLE=Bootstrap.exe +CLIENT_HEALTH_CHECK_TIMEOUT_MS=15000 + +# 崩溃报告接口。已给默认值,可直接试跑;正式部署建议修改。 +CRASH_SERVICE_VERSION=1.0.0 +CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 +CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 + +# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。 +CRASH_ADMIN_TOKEN= + +# 崩溃报告大小限制。通常不用改。 +CRASH_METADATA_MAX_KB=256 +CRASH_MINIDUMP_MAX_MB=128 +CRASH_ATTACHMENTS_MAX_MB=64 +CRASH_REQUEST_MAX_MB=200 +CRASH_SYMBOLS_MAX_MB=512 + +# MinIO 端口。通常不用改;如果端口被占用再改。 +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +# MinIO 用户名和密码。Docker 启动 MinIO 时会用这里的值初始化账号;已给默认值,可直接试跑,正式部署建议修改。 +MINIO_ACCESS_KEY=simcae_minio_admin +MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe + +# MinIO 存储桶名称。通常不用改。 +MINIO_BUCKET=updates + +# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 +MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 + +# MinIO 预签名下载链接有效期,单位分钟。通常不用改。 +SIGN_EXPIRE_MIN=60 + +# MinIO 连接超时和重试参数。通常不用改。 +MINIO_CONNECT_TIMEOUT_SEC=2 +MINIO_READ_TIMEOUT_SEC=5 +MINIO_RETRY_TOTAL=1 +MINIO_HEALTH_TIMEOUT_SEC=2 diff --git a/.gitignore b/.gitignore index 9ef82b1..f52594f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,11 +30,20 @@ __pycache__/ .coverage htmlcov/ +# Frontend dependencies and build output +node_modules/ +admin-ui/node_modules/ +admin-ui/dist/ + # Local configuration and secrets .env .env.* !.env.example !.env.docker.example +!admin-ui/.env +!admin-ui/.env.development +!admin-ui/.env.production +!admin-ui/.env.staging admin_token.sha256 keys/*private*.pem keys/*private*.key diff --git a/Dockerfile b/Dockerfile index 2bccc45..16316d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +FROM node:22-alpine AS admin_ui_builder + +WORKDIR /ui + +COPY admin-ui/package*.json ./ +RUN npm ci + +COPY admin-ui ./ +RUN npm run build + + FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -10,15 +21,19 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends libarchive-tools \ && rm -rf /var/lib/apt/lists/* -COPY server/requirements.txt ./requirements.txt +COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt -COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./ -COPY --chown=updateapp:updateapp server/admin.html ./admin.html +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 RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ && chown -R updateapp:updateapp /app /data \ - && chmod 644 /app/admin.html + && 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 {} \; USER updateapp diff --git a/Docs/ReadMe.txt b/Docs/ReadMe.txt new file mode 100644 index 0000000..949d27c --- /dev/null +++ b/Docs/ReadMe.txt @@ -0,0 +1,38 @@ +服务端文档入口 +============== + +本目录用于保存 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 +``` diff --git a/Docs/后端模板化改造说明.md b/Docs/后端模板化改造说明.md new file mode 100644 index 0000000..36cb265 --- /dev/null +++ b/Docs/后端模板化改造说明.md @@ -0,0 +1,124 @@ +# 后端成熟模板化改造说明 + +## 目标 + +服务端后端不再继续把所有配置、鉴权、审计、页面入口和业务接口堆在 `main.py` 里,而是逐步改成成熟 FastAPI 后台项目常见的分层结构。 + +参考方向: + +- FastAPI 官方 full-stack 模板:后端工程结构、配置、JWT、数据库模型、迁移、Docker。 +- FastAPI Users:用户、认证、JWT、密码哈希、用户管理。 +- 现有 SimCAE 业务:版本发布、Manifest 签名、License、设备、崩溃报告、日志审计。 + +## 当前已落地 + +已经新增模板化目录: + +```text +app/ + api/ + router.py + routes/ + admin_license.py + admin_policy.py + admin_version.py + admin_app_channel.py + admin_crash_report.py + admin_device.py + admin_logs.py + admin_config.py + admin_publish.py + client_update.py + crash_api.py + frontend.py + core/ + config.py + security.py + audit.py + db/ + repositories/ + app_channel_repository.py + crash_report_repository.py + device_repository.py + license_repository.py + log_repository.py + policy_repository.py + version_repository.py + client_update_repository.py + publish_repository.py + schemas/ + license.py + policy.py + version.py + app_channel.py + device.py + log.py + admin_config.py + client_update.py + services/ + common_service.py + license_service.py + signing_service.py + admin_config_service.py + device_credential_service.py + publish_service.py + crash_api_service.py +``` + +当前已经从 `main.py` 抽出的通用能力: + +- `app/core/config.py`:统一读取 `.env`、路径配置和基础 settings。 +- `app/core/security.py`:后台令牌校验、令牌摘要、`.env` 写入工具。 +- `app/core/audit.py`:管理后台写操作审计中间件。 +- `app/api/routes/frontend.py`:管理后台静态页面、favicon、platform-config、health 路由。 +- `app/api/routes/admin_license.py`:License 创建、列表、禁用/启用、软删除接口。 +- `app/api/routes/admin_policy.py`:渠道更新策略查询和保存接口。 +- `app/api/routes/admin_version.py`:版本列表、设为最新、删除、客户端协议修改、离线包下载接口。 +- `app/api/routes/admin_app_channel.py`:应用列表、应用创建、渠道列表、渠道保存接口。 +- `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_publish.py`:管理端发布新版本接口,包含发布锁和管理员鉴权。 +- `app/api/routes/client_update.py`:客户端设备登记、更新检测、下载链接、Manifest、下载日志和升级结果上报接口。 +- `app/api/routes/crash_api.py`:崩溃报告健康检查、报告上传/下载和符号包上传接口。 +- `app/schemas/`:License、策略、版本管理接口的 Pydantic 请求模型。 +- `app/services/common_service.py`:渠道校验、版本号比较、策略行转换等通用业务函数。 +- `app/services/license_service.py`:License Key 加密保存和解密展示。 +- `app/services/signing_service.py`:Manifest、策略、设备身份签名。 +- `app/services/admin_config_service.py`:管理端运行时配置和客户端配置默认值生成。 +- `app/services/device_credential_service.py`:客户端设备凭证验签、有效性校验和 last_seen 更新。 +- `app/services/publish_service.py`:发布包表单解析、Release 目录校验、压缩包解压、空间检查和 MinIO 上传。 +- `app/services/crash_api_service.py`:崩溃报告接口鉴权、multipart 校验、文件落盘、幂等校验和符号包处理。 +- `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。 +- `app/api/router.py`:统一注册基础路由。 +- `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。 + +当前业务接口路径保持不变,避免影响 Launcher、Updater 和已部署管理页面。 + +## 后续迁移路线 + +后续不建议一次性重写全部后端,而是按下面顺序迁移: + +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。 +9. 保持客户端接口 `/api/v1/...` 尽量兼容,避免客户端 SDK 重编。 + +## 为什么不是直接删除 main.py + +`main.py` 原来包含大量已经跑通的业务逻辑: + +- 版本发布和压缩包解包。 +- Manifest 哈希和签名。 +- MinIO 文件上传、下载授权。 +- 设备身份签发和校验。 +- License 管理。 +- 崩溃报告上传、查询和下载。 + +这些是 SimCAE 自有业务,GitHub 模板不会直接提供。当前已经完成第一轮“搬家式重构”:业务模块进入 `routes/services/repositories`,入口文件不再堆业务代码。后续可以继续做更深的工程化,例如 ORM、JWT 用户体系、角色权限和 Alembic 数据库迁移。 diff --git a/Docs/服务端部署说明.md b/Docs/服务端部署说明.md new file mode 100644 index 0000000..df2c26c --- /dev/null +++ b/Docs/服务端部署说明.md @@ -0,0 +1,417 @@ +# SimCAE 服务端 Docker 部署包说明 + +本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。 + +服务端 Docker 包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、pure-admin(Vue3 + Element Plus)管理后台、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;目标服务器即使不能联网,也可以按本文完成部署。 + +如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。 + +## 一、维护者:生成 Docker 离线部署包 + +在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。 + +进入服务端源码目录: + +```bash +cd update-server +``` + +执行打包脚本: + +```bash +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage +``` + +脚本会做这些事情: + +```text +1. 构建 simcae-update-server:0.1.0 镜像。 +2. 拉取 MinIO 和 MinIO Client 镜像。 +3. 复制 docker-compose.yml、.env.example、签名密钥和部署说明。 +4. 把 API、MinIO、MinIO Client 三个镜像保存到 images/simcae-server-all-images_0.1.0.tar。 +5. 生成完整目录 dist/SimCAEServerDockerPackage/。 +6. 生成最终交付压缩包 dist/SimCAEServerDockerPackage.tar.gz。 +``` + +打包完成后,重点确认这两个输出: + +```text +dist/SimCAEServerDockerPackage/ +dist/SimCAEServerDockerPackage.tar.gz +``` + +通常只需要把下面这个文件发给部署人员或拷贝到目标服务器: + +```text +dist/SimCAEServerDockerPackage.tar.gz +``` + +如果只是重新打包已有镜像,可以使用: + +```bash +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage \ + --skip-build +``` + +如果打包机器不能联网,但 MinIO 镜像已经提前存在本机,可以使用: + +```bash +bash ./scripts/package-offline-server.sh \ + --version 0.1.0 \ + --output-dir ./dist/SimCAEServerDockerPackage \ + --skip-pull +``` + +注意:`keys/manifest_private_key.pem` 会被放进部署包,用于服务端发布版本时签名 Manifest。这个私钥必须保护好,不要公开上传。 + +## 二、这个包里面有什么 + +解压后目录结构如下: + +```text +SimCAEServerDockerPackage/ + README.md 当前说明文档 + docker-compose.yml 容器编排文件 + .env.example 环境变量模板 + load-images.sh 导入 Docker 镜像并创建 .env 的脚本 + images/ + simcae-server-all-images_0.1.0.tar Docker 镜像包,包含 api、minio、minio-init + keys/ + manifest_private_key.pem 服务端 Manifest 签名私钥 + manifest_public_key.pem 与客户端配套的验签公钥 +``` + +这些文件的关系可以这样理解: + +```text +images/*.tar 程序镜像包,相当于安装材料 +docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载 +.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成 +keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签 +runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等 +minio_data/ 启动后自动生成,保存升级包文件 +``` + +## 三、使用者:最小部署流程 + +先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行: + +```bash +tar -xzf SimCAEServerDockerPackage.tar.gz +cd SimCAEServerDockerPackage +pwd +ls +``` + +你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到: + +```text +docker-compose.yml +.env.example +load-images.sh +images/ +keys/ +``` + +后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。 + +继续在 `SimCAEServerDockerPackage` 目录执行: + +```bash +bash ./load-images.sh +``` + +这个脚本会做两件事: + +```text +1. docker load 导入 images/simcae-server-all-images_0.1.0.tar 里的镜像 +2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env +3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限 +``` + +然后修改 `.env`: + +```bash +nano .env +``` + +最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +例如服务器 IP 是 `192.168.229.128`: + +```text +MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000 +``` + +启动服务: + +```bash +docker compose up -d +``` + +查看状态和后端日志: + +```bash +docker compose ps +docker compose logs -f api +``` + +浏览器访问: + +```text +后台/API: http://你的服务器IP:8000/ +MinIO 控制台: http://你的服务器IP:9001/ +``` + +如果服务器开启防火墙,至少放行: + +```text +8000 后台/API +9000 客户端下载升级文件 +9001 MinIO 控制台,可选 +``` + +## 四、.env 配置怎么填 + +`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。 + +### 必须确认或修改 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 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`,所以默认是这个。 | +| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | +| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | +| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 | +| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | +| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | + +### 通常不用改 + +| 字段 | 作用 | 默认值说明 | +| --- | --- | --- | +| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:0.1.0`。 | +| `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`。 | +| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 | +| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | +| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | +| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 | +| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 | +| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 | + +### 发布保护参数 + +这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。 + +| 字段 | 作用 | 默认值 | +| --- | --- | --- | +| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` | +| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` | +| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` | +| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` | + +如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。 + +### 崩溃报告接口参数 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 | +| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 | +| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 | +| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 | +| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 | +| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 | +| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 | +| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 | +| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 | + +## 五、客户端配置要同步哪些值 + +推荐直接使用后台页面生成: + +```text +1. 登录后台 +2. 选择应用和渠道 +3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成” +4. 打开“客户端配置生成” +5. 点击“生成配套配置” +6. 点击“复制配置” +7. 粘贴到客户端 bin/config/app_config.json +``` + +客户端 `config/app_config.json` 至少要和服务端保持这两个值一致: + +```json +{ + "api_base_url": "http://你的服务器IP:8000", + "client_token": "和服务端 CLIENT_API_TOKEN 一样" +} +``` + +如果服务端 `.env` 里保持默认: + +```text +CLIENT_API_TOKEN=SimCAEClientToken2026 +``` + +客户端就填: + +```json +"client_token": "SimCAEClientToken2026" +``` + +`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。 + +## 六、发布新版本 + +进入后台后,“发布新版本”支持两种方式: + +```text +1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe +2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar +``` + +压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: + +```text +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe +``` + +`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。 + +## 七、常用命令 + +确认你仍然在 `SimCAEServerDockerPackage` 目录: + +```bash +pwd +``` + +启动: + +```bash +docker compose up -d +``` + +查看状态: + +```bash +docker compose ps +``` + +查看后端日志: + +```bash +docker compose logs -f api +``` + +查看所有容器日志: + +```bash +docker compose logs -f +``` + +重启后端: + +```bash +docker compose restart api +``` + +停止服务: + +```bash +docker compose down +``` + +如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。 + +## 八、数据、备份和迁移 + +运行后会生成这些目录或文件: + +```text +.env 当前服务器实际配置 +runtime/ 服务端数据库、上传临时目录、崩溃报告等 +minio_data/ MinIO 对象数据,也就是上传后的升级文件 +keys/ Manifest 签名密钥 +``` + +备份或迁移正式环境时,重点备份: + +```text +.env +runtime/ +minio_data/ +keys/ +docker-compose.yml +``` + +不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。 + +## 九、常见问题 + +### 启动后看不到后端日志 + +`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志: + +```bash +docker compose logs -f api +``` + +### API 容器反复重启,提示 Permission denied: '/data/uploads' + +这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行: + +```bash +docker compose down +mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage +sudo chown -R $(id -u):$(id -g) runtime +docker compose up -d +``` + +### 网页能打开,但客户端提示令牌校验失败 + +检查客户端 `config/app_config.json`: + +```json +"client_token": "..." +``` + +必须和服务端 `.env`: + +```text +CLIENT_API_TOKEN=... +``` + +完全一致。 + +### 客户端能连 API,但下载升级文件失败 + +检查 `.env`: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。 + +## 十、安全提醒 + +`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 + +默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 diff --git a/admin-ui/.browserslistrc b/admin-ui/.browserslistrc new file mode 100644 index 0000000..40bd99c --- /dev/null +++ b/admin-ui/.browserslistrc @@ -0,0 +1,4 @@ +> 1% +last 2 versions +not dead +not ie 11 \ No newline at end of file diff --git a/admin-ui/.dockerignore b/admin-ui/.dockerignore new file mode 100644 index 0000000..0376edd --- /dev/null +++ b/admin-ui/.dockerignore @@ -0,0 +1,21 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.eslintcache +report.html + +yarn.lock +npm-debug.log* +.pnpm-error.log* +.pnpm-debug.log +tests/**/coverage/ + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +tsconfig.tsbuildinfo diff --git a/admin-ui/.editorconfig b/admin-ui/.editorconfig new file mode 100644 index 0000000..ea6e20f --- /dev/null +++ b/admin-ui/.editorconfig @@ -0,0 +1,14 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/admin-ui/.env b/admin-ui/.env new file mode 100644 index 0000000..09344c1 --- /dev/null +++ b/admin-ui/.env @@ -0,0 +1,5 @@ +# 平台本地运行端口号 +VITE_PORT = 8848 + +# 是否隐藏首页 隐藏 true 不隐藏 false (勿删除,VITE_HIDE_HOME只需在.env文件配置) +VITE_HIDE_HOME = false diff --git a/admin-ui/.env.development b/admin-ui/.env.development new file mode 100644 index 0000000..90d1146 --- /dev/null +++ b/admin-ui/.env.development @@ -0,0 +1,8 @@ +# 平台本地运行端口号 +VITE_PORT = 8848 + +# 开发环境读取配置文件路径 +VITE_PUBLIC_PATH = / + +# 开发环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") +VITE_ROUTER_HISTORY = "hash" diff --git a/admin-ui/.env.production b/admin-ui/.env.production new file mode 100644 index 0000000..84e6086 --- /dev/null +++ b/admin-ui/.env.production @@ -0,0 +1,13 @@ +# 线上环境平台打包路径 +VITE_PUBLIC_PATH = / + +# 线上环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") +VITE_ROUTER_HISTORY = "hash" + +# 是否在打包时使用cdn替换本地库 替换 true 不替换 false +VITE_CDN = false + +# 是否启用gzip压缩或brotli压缩(分两种情况,删除原始文件和不删除原始文件) +# 压缩时不删除原始文件的配置:gzip、brotli、both(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +# 压缩时删除原始文件的配置:gzip-clear、brotli-clear、both-clear(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +VITE_COMPRESSION = "none" \ No newline at end of file diff --git a/admin-ui/.env.staging b/admin-ui/.env.staging new file mode 100644 index 0000000..65b57e3 --- /dev/null +++ b/admin-ui/.env.staging @@ -0,0 +1,16 @@ +# 预发布也需要生产环境的行为 +# https://cn.vitejs.dev/guide/env-and-mode.html#modes +# NODE_ENV = development + +VITE_PUBLIC_PATH = / + +# 预发布环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") +VITE_ROUTER_HISTORY = "hash" + +# 是否在打包时使用cdn替换本地库 替换 true 不替换 false +VITE_CDN = true + +# 是否启用gzip压缩或brotli压缩(分两种情况,删除原始文件和不删除原始文件) +# 压缩时不删除原始文件的配置:gzip、brotli、both(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +# 压缩时删除原始文件的配置:gzip-clear、brotli-clear、both-clear(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) +VITE_COMPRESSION = "none" diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..423ed2b --- /dev/null +++ b/admin-ui/.gitignore @@ -0,0 +1,22 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.eslintcache +report.html +vite.config.*.timestamp* + +yarn.lock +npm-debug.log* +.pnpm-error.log* +.pnpm-debug.log +tests/**/coverage/ + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/admin-ui/.husky/commit-msg b/admin-ui/.husky/commit-msg new file mode 100755 index 0000000..5ee2d16 --- /dev/null +++ b/admin-ui/.husky/commit-msg @@ -0,0 +1,8 @@ +#!/bin/sh + +# shellcheck source=./_/husky.sh +. "$(dirname "$0")/_/husky.sh" + +PATH="/usr/local/bin:$PATH" + +npx --no-install commitlint --edit "$1" \ No newline at end of file diff --git a/admin-ui/.husky/common.sh b/admin-ui/.husky/common.sh new file mode 100644 index 0000000..5f0540b --- /dev/null +++ b/admin-ui/.husky/common.sh @@ -0,0 +1,9 @@ +#!/bin/sh +command_exists () { + command -v "$1" >/dev/null 2>&1 +} + +# Workaround for Windows 10, Git Bash and Pnpm +if command_exists winpty && test -t 1; then + exec < /dev/tty +fi diff --git a/admin-ui/.husky/pre-commit b/admin-ui/.husky/pre-commit new file mode 100755 index 0000000..6e229ea --- /dev/null +++ b/admin-ui/.husky/pre-commit @@ -0,0 +1,10 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/common.sh" + +[ -n "$CI" ] && exit 0 + +PATH="/usr/local/bin:$PATH" + +# Perform lint check on files in the staging area through .lintstagedrc configuration +pnpm exec lint-staged \ No newline at end of file diff --git a/admin-ui/.lintstagedrc b/admin-ui/.lintstagedrc new file mode 100644 index 0000000..ebf359a --- /dev/null +++ b/admin-ui/.lintstagedrc @@ -0,0 +1,20 @@ +{ + "*.{js,jsx,ts,tsx}": [ + "prettier --cache --ignore-unknown --write", + "eslint --cache --fix" + ], + "{!(package)*.json,*.code-snippets,.!({browserslist,npm,nvm})*rc}": [ + "prettier --cache --write--parser json" + ], + "package.json": ["prettier --cache --write"], + "*.vue": [ + "prettier --write", + "eslint --cache --fix", + "stylelint --fix --allow-empty-input" + ], + "*.{css,scss,html}": [ + "prettier --cache --ignore-unknown --write", + "stylelint --fix --allow-empty-input" + ], + "*.md": ["prettier --cache --ignore-unknown --write"] +} diff --git a/admin-ui/.markdownlint.json b/admin-ui/.markdownlint.json new file mode 100644 index 0000000..d628d44 --- /dev/null +++ b/admin-ui/.markdownlint.json @@ -0,0 +1,11 @@ +{ + "default": true, + "MD003": false, + "MD033": false, + "MD013": false, + "MD001": false, + "MD025": false, + "MD024": false, + "MD007": { "indent": 4 }, + "no-hard-tabs": false +} diff --git a/admin-ui/.npmrc b/admin-ui/.npmrc new file mode 100644 index 0000000..dddf8bc --- /dev/null +++ b/admin-ui/.npmrc @@ -0,0 +1,4 @@ +shell-emulator=true +shamefully-hoist=true +enable-pre-post-scripts=false +strict-peer-dependencies=false \ No newline at end of file diff --git a/admin-ui/.nvmrc b/admin-ui/.nvmrc new file mode 100644 index 0000000..c07f712 --- /dev/null +++ b/admin-ui/.nvmrc @@ -0,0 +1 @@ +v22.20.0 \ No newline at end of file diff --git a/admin-ui/.prettierrc.js b/admin-ui/.prettierrc.js new file mode 100644 index 0000000..775d970 --- /dev/null +++ b/admin-ui/.prettierrc.js @@ -0,0 +1,9 @@ +// @ts-check + +/** @type {import("prettier").Config} */ +export default { + bracketSpacing: true, + singleQuote: false, + arrowParens: "avoid", + trailingComma: "none" +}; diff --git a/admin-ui/.stylelintignore b/admin-ui/.stylelintignore new file mode 100644 index 0000000..0c34e61 --- /dev/null +++ b/admin-ui/.stylelintignore @@ -0,0 +1,4 @@ +/dist/* +/public/* +public/* +src/style/reset.scss \ No newline at end of file diff --git a/admin-ui/Dockerfile b/admin-ui/Dockerfile new file mode 100644 index 0000000..cd6d51a --- /dev/null +++ b/admin-ui/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-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 . . +RUN pnpm build + +FROM nginx:stable-alpine as production-stage + +COPY --from=build-stage /app/dist /usr/share/nginx/html +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/admin-ui/LICENSE b/admin-ui/LICENSE new file mode 100644 index 0000000..6d4889d --- /dev/null +++ b/admin-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present, pure-admin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/admin-ui/README.en-US.md b/admin-ui/README.en-US.md new file mode 100644 index 0000000..104b8d5 --- /dev/null +++ b/admin-ui/README.en-US.md @@ -0,0 +1,39 @@ +
{props.title}
+ )} +,
+ config?: PureHttpRequestConfig
+ ): Promise ,
+ config?: PureHttpRequestConfig
+ ): Promise
+ 403
+
+ 抱歉,你无权访问该页面
+
+ 404
+
+ 抱歉,你访问的页面不存在
+
+ 500
+
+ 抱歉,服务器出错了
+ 当前拥有的code列表:{{ getAuths() }} 当前拥有的code列表:{{ permissions }}
+ *:*:* 代表拥有全部按钮级别权限
+
+ 模拟后台根据不同角色返回对应路由,观察左侧菜单变化(管理员角色可查看系统管理菜单、普通角色不可查看系统管理菜单)
+ 应用、版本、License、设备、崩溃报告和审计日志统一管理
+
{{ title }}
+ SimCAE 升级服务管理
+ {{ generatedClientConfig || "尚未生成客户端配置" }}
+
+
+ License ID 客户 渠道 设备 有效期 状态 操作
+
+
+ License ID 客户 渠道 设备 有效期 License 状态 操作 客户端配置生成
+ 尚未生成客户端配置
';return;}
- try { const data=await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`); const rows=data.list||[]; licenseRows.innerHTML=rows.length?'':'请先选择应用 '; rows.forEach(item=>{const tr=document.createElement('tr');tr.innerHTML=`暂无授权 ${item.license_id} ${item.customer_name} ${item.channel_code} ${item.used_devices} / ${item.max_devices} ${item.valid_until} ${item.status==='active'?'有效':'已禁用'} `;licenseRows.appendChild(tr);}); } catch(err){log(err);notify('读取授权失败:' + friendlyErrorMessage(err), true);}
+ const appId = currentAppId || publishAppIdInput.value.trim();
+ if (!appId) {
+ latestLicenses = [];
+ licenseRows.innerHTML = ' ';
+ return;
+ }
+ try {
+ const data = await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`);
+ const rows = data.list || [];
+ latestLicenses = rows;
+ licenseRows.innerHTML = rows.length ? '' : '请先选择应用 ';
+ rows.forEach(item => {
+ const hasKey = Boolean(item.license_key);
+ const licenseHtml = hasKey
+ ? `${escapeHtml(item.license_key)}`
+ : '旧授权不可查看,请重新创建 License';
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ 暂无授权 ${escapeHtml(item.license_id)}
+ ${escapeHtml(item.customer_name)}
+ ${escapeHtml(item.channel_code)}
+ ${item.used_devices} / ${item.max_devices}
+ ${escapeHtml(item.valid_until)}
+ ${licenseHtml}
+ ${item.status === 'active' ? '有效' : '已禁用'}
+
+ ${hasKey ? `` : ''}
+ ${hasKey ? `` : ''}
+
+ `;
+ licenseRows.appendChild(tr);
+ });
+ if (!configLicenseKeyInput.value.trim()) {
+ const currentChannel = publishChannelInput.value.trim() || document.getElementById('licenseChannel').value;
+ const firstUsable = rows.find(item => item.status === 'active' && item.license_key && item.channel_code === currentChannel)
+ || rows.find(item => item.status === 'active' && item.license_key);
+ if (firstUsable) {
+ configLicenseKeyInput.value = firstUsable.license_key;
+ clientConfigSummary.textContent = '已自动选用一个有效 License,可直接生成客户端配置。';
+ }
+ }
+ } catch (err) {
+ latestLicenses = [];
+ log(err);
+ notify('读取授权失败:' + friendlyErrorMessage(err), true);
+ }
}
async function createLicense(){
const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;} const body={app_id:appId,customer_name:document.getElementById('licenseCustomer').value.trim(),channel:document.getElementById('licenseChannel').value,max_devices:Number(document.getElementById('licenseMaxDevices').value),valid_until:document.getElementById('licenseValidUntil').value.trim()};
- try{const result=await request('/admin/license/create',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});document.getElementById('createdLicenseKey').value=result.license_key;log(result);notify('授权已创建,请立即复制密钥');await loadLicenses();}catch(err){log(err);notify('创建授权失败:' + friendlyErrorMessage(err), true);}
+ try{const result=await request('/admin/license/create',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});document.getElementById('createdLicenseKey').value=result.license_key;configLicenseKeyInput.value=result.license_key;log(result);notify('授权已创建,已填入客户端配置生成区');await loadLicenses();}catch(err){log(err);notify('创建授权失败:' + friendlyErrorMessage(err), true);}
}
async function toggleLicense(id,status){try{await request('/admin/license/set-status',{method:'POST',contentType:'application/json',body:JSON.stringify({license_id:id,status:status==='active'?'disabled':'active'})});await loadLicenses();notify('授权状态已更新');}catch(err){log(err);notify('修改授权失败:' + friendlyErrorMessage(err), true);}}
+ async function generateClientConfig() {
+ const appId = currentAppId || publishAppIdInput.value.trim();
+ const channel = publishChannelInput.value.trim() || document.getElementById('licenseChannel').value;
+ const version = publishVersionInput.value.trim() || '1.0.0';
+ const protocol = Math.max(1, Number(publishProtocolInput.value) || 3);
+ const licenseKey = configLicenseKeyInput.value.trim() || document.getElementById('createdLicenseKey').value.trim();
+ const apiUrl = configApiBaseUrlInput.value.trim() || runtimeClientConfig.api_base_url || apiBase;
+ const mainExecutable = configMainExecutableInput.value.trim() || inferredMainExecutable() || runtimeClientConfig.main_executable || 'SimCAE.exe';
+ if (!appId) { notify('请先选择应用或填写 App ID', true); return; }
+ if (!channel) { notify('当前应用没有可用渠道', true); return; }
+ try {
+ const result = await request('/admin/client-config/generate', {
+ method: 'POST',
+ contentType: 'application/json',
+ body: JSON.stringify({
+ app_id: appId,
+ app_name: currentAppName(),
+ channel,
+ current_version: version,
+ client_protocol: protocol,
+ license_key: licenseKey,
+ api_base_url: apiUrl,
+ main_executable: mainExecutable,
+ }),
+ });
+ latestClientConfigText = result.json_text || JSON.stringify(result.config || {}, null, 2);
+ generatedClientConfig.textContent = latestClientConfigText;
+ copyClientConfigBtn.disabled = !latestClientConfigText;
+ clientConfigSummary.textContent = licenseKey
+ ? '配置已生成;复制到客户端 bin/config/app_config.json。'
+ : '配置已生成但 License 为空;客户端首次启动时会提示用户输入 License。';
+ log({ client_config: result.config });
+ notify('客户端配置已生成');
+ } catch (err) {
+ log(err);
+ notify('生成客户端配置失败:' + friendlyErrorMessage(err), true);
+ }
+ }
+
+ async function copyTextToClipboard(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ textarea.remove();
+ }
+
+ async function copyClientConfig() {
+ const text = latestClientConfigText || generatedClientConfig.textContent.trim();
+ if (!text || text === '尚未生成客户端配置') { notify('请先生成客户端配置', 'info'); return; }
+ try {
+ await copyTextToClipboard(text);
+ notify('客户端配置已复制');
+ } catch (err) {
+ log(err);
+ notify('复制失败,请手动选中配置内容复制', true);
+ }
+ }
+
+ function fillLicenseIntoConfig(licenseId) {
+ const item = latestLicenses.find(x => x.license_id === licenseId);
+ if (!item || !item.license_key) {
+ notify('这条授权没有可查看的 License Key,请重新创建 License', true);
+ return;
+ }
+ configLicenseKeyInput.value = item.license_key;
+ document.getElementById('createdLicenseKey').value = item.license_key;
+ clientConfigSummary.textContent = `已选用 ${item.customer_name || item.license_id} 的 License。`;
+ notify('License 已填入客户端配置生成区');
+ }
+
+ async function copyLicenseKey(licenseId) {
+ const item = latestLicenses.find(x => x.license_id === licenseId);
+ if (!item || !item.license_key) {
+ notify('这条授权没有可复制的 License Key,请重新创建 License', true);
+ return;
+ }
+ try {
+ await copyTextToClipboard(item.license_key);
+ notify('License 已复制');
+ } catch (err) {
+ log(err);
+ notify('复制 License 失败,请手动选中复制', true);
+ }
+ }
+
async function loadDevices() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) { deviceRows.innerHTML = ' '; return; }
@@ -2265,6 +2483,10 @@
currentToken = adminTokenInput.value.trim() || currentToken;
});
+ configMainExecutableInput.addEventListener('input', () => {
+ clientMainExecutableTouched = Boolean(configMainExecutableInput.value.trim());
+ });
+
toggleTokenBtn.addEventListener('click', () => {
const showing = adminTokenInput.type === 'text';
adminTokenInput.type = showing ? 'password' : 'text';
@@ -2351,7 +2573,20 @@
document.getElementById('refreshDevicesBtn').addEventListener('click', loadDevices);
document.getElementById('refreshLicensesBtn').addEventListener('click', loadLicenses);
document.getElementById('createLicenseBtn').addEventListener('click', createLicense);
- licenseRows.addEventListener('click', event=>{const b=event.target.closest('button[data-license-id]');if(b)toggleLicense(b.dataset.licenseId,b.dataset.status);});
+ document.getElementById('generateClientConfigBtn').addEventListener('click', generateClientConfig);
+ copyClientConfigBtn.addEventListener('click', copyClientConfig);
+ licenseRows.addEventListener('click', event => {
+ const b = event.target.closest('button[data-license-id]');
+ if (!b) return;
+ const action = b.dataset.licenseAction || 'toggle';
+ if (action === 'fill') {
+ fillLicenseIntoConfig(b.dataset.licenseId);
+ } else if (action === 'copy') {
+ copyLicenseKey(b.dataset.licenseId);
+ } else {
+ toggleLicense(b.dataset.licenseId, b.dataset.status);
+ }
+ });
deviceRows.addEventListener('click', event => { const b=event.target.closest('button[data-device-id]'); if(b) toggleDevice(b.dataset.deviceId, b.dataset.disabled === 'true'); });
crashReportRows.addEventListener('click', event => { const b=event.target.closest('button[data-crash-file]'); if(b) downloadCrashReportFile(b.dataset.crashReportId, b.dataset.crashFile); });
reportRows.addEventListener('click', event => { const b=event.target.closest('button[data-log-action="delete-report"]'); if(b) deleteLog('/admin/report/delete', b.dataset.id, loadReports, '升级日志'); });
@@ -2367,9 +2602,14 @@
publishAppIdInput.value = currentAppId;
}
currentAppLabel.textContent = currentAppId || '未选择';
+ syncClientConfigDefaults();
if (currentAppId) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
});
+ publishAppIdInput.addEventListener('change', () => {
+ if (!currentAppId) syncClientConfigDefaults();
+ });
+
versionRows.addEventListener('click', async (event) => {
const button = event.target.closest('button');
if (!button) return;
@@ -2410,8 +2650,12 @@
deviceRows.innerHTML = '请先选择应用 ';
crashReportRows.innerHTML = '登录后显示设备 ';
crashReportCount.textContent = '登录后加载';
- licenseRows.innerHTML = '登录后显示崩溃报告 ';
+ licenseRows.innerHTML = '登录后显示授权 ';
channelRows.innerHTML = '登录后显示授权 ';
+ generatedClientConfig.textContent = '登录后生成客户端配置';
+ clientConfigSummary.textContent = '生成后复制到客户端 bin/config/app_config.json。';
+ latestClientConfigText = '';
+ copyClientConfigBtn.disabled = true;
log('请输入管理员令牌后点击“登录并保存”。页面不会在打开时自动读取服务端日志。当前连接服务端:' + apiBase);
}
diff --git a/main.py b/main.py
index d92882d..c84fc3a 100644
--- a/main.py
+++ b/main.py
@@ -1,124 +1,35 @@
-from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends
-from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
-from pydantic import BaseModel
import os
+import shutil
import sqlite3
-from pathlib import Path, PurePosixPath
-from dotenv import load_dotenv
+import subprocess
+import tempfile
+import time
+import urllib.request
from contextlib import asynccontextmanager
-import asyncio
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from fastapi.staticfiles import StaticFiles
+
import db
import minio_tool
-import hashlib
-import secrets
-import base64
-import json
-import uuid
-from datetime import datetime, timezone, timedelta
-from fastapi.middleware.cors import CORSMiddleware
-import socket
-import subprocess
-import time
-import shutil
-import tempfile
-import urllib.request
-import zipfile
-import tarfile
-from cryptography.hazmat.primitives.serialization import load_pem_private_key
-from cryptography.hazmat.primitives.asymmetric import padding
-from cryptography.hazmat.primitives import hashes
-
-# 加载环境变量
-BASE_DIR = Path(__file__).resolve().parent
-ENV_FILE_PATH = Path(os.getenv("ENV_FILE_PATH", str(BASE_DIR / ".env"))).expanduser()
-if not ENV_FILE_PATH.is_absolute():
- ENV_FILE_PATH = BASE_DIR / ENV_FILE_PATH
-load_dotenv(ENV_FILE_PATH)
+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.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
-def env_bool(name: str, default: bool = False) -> bool:
- value = os.getenv(name)
- return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"}
-
-
-def configured_path(name: str, default: str) -> Path:
- path = Path(os.getenv(name, default)).expanduser()
- return path if path.is_absolute() else BASE_DIR / path
-
-
-SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0")
-SERVER_PORT = int(os.getenv("SERVER_PORT", "8000"))
+SERVER_HOST = settings.server_host
+SERVER_PORT = settings.server_port
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
-TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows")
-TARGET_ARCH = os.getenv("TARGET_ARCH", "x64")
-RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/")
-SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1")
-
-# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。
-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)
MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data")
-UPLOAD_SPACE_RESERVE = int(os.getenv("UPLOAD_SPACE_RESERVE_MB", "256")) * 1024 * 1024
-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)))
-PUBLISH_LOCK = asyncio.Lock()
-
-# SimCAE 崩溃报告私有存储与上传限制
-CRASH_STORAGE_ROOT = configured_path("CRASH_STORAGE_ROOT", "crash_storage")
-CRASH_METADATA_MAX_BYTES = int(os.getenv("CRASH_METADATA_MAX_KB", "256")) * 1024
-CRASH_MINIDUMP_MAX_BYTES = int(os.getenv("CRASH_MINIDUMP_MAX_MB", "128")) * 1024 * 1024
-CRASH_ATTACHMENTS_MAX_BYTES = int(os.getenv("CRASH_ATTACHMENTS_MAX_MB", "64")) * 1024 * 1024
-CRASH_REQUEST_MAX_BYTES = int(os.getenv("CRASH_REQUEST_MAX_MB", "200")) * 1024 * 1024
-CRASH_SYMBOLS_MAX_BYTES = int(os.getenv("CRASH_SYMBOLS_MAX_MB", "512")) * 1024 * 1024
-CRASH_SERVICE_VERSION = os.getenv("CRASH_SERVICE_VERSION", "1.0.0")
-
-# 后台管理令牌:当前版本直接使用 .env 中的 ADMIN_TOKEN 明文配置。
-ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "").strip()
-if not ADMIN_TOKEN:
- raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN")
-
-def token_digest(token: str) -> str:
- return hashlib.sha256(token.encode("utf-8")).hexdigest()
+tempfile.tempdir = str(UPLOAD_SPOOL_DIR)
-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):
- return value
- return '"' + value.replace('\\', '\\\\').replace('"', '\"') + '"'
-
-
-def persist_env_value(path: Path, key: str, value: str) -> bool:
- line = f"{key}={quote_env_value(value)}\n"
- if path.exists():
- lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
- replaced = False
- for index, item in enumerate(lines):
- stripped = item.lstrip()
- if stripped.startswith("#") or "=" not in stripped:
- continue
- name = stripped.split("=", 1)[0].strip()
- if name == key:
- lines[index] = line
- replaced = True
- break
- if not replaced:
- if lines and not lines[-1].endswith(("\n", "\r")):
- lines[-1] += "\n"
- lines.append(line)
- path.write_text("".join(lines), encoding="utf-8")
- return True
- path.write_text(line, encoding="utf-8")
- return True
-
-CRASH_REPORT_TOKEN = os.getenv("CRASH_REPORT_TOKEN") or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN") or VALID_CLIENT_TOKEN
-CRASH_SYMBOL_TOKEN = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN") or ADMIN_TOKEN
-CRASH_ADMIN_TOKEN = os.getenv("CRASH_ADMIN_TOKEN")
-
-# ========== 生命周期初始化 ==========
@asynccontextmanager
async def lifespan(app: FastAPI):
print("===== 进程启动,开始初始化数据库 =====")
@@ -136,28 +47,37 @@ async def lifespan(app: FastAPI):
if "client_protocol" not in version_columns:
cur.execute("ALTER TABLE versions ADD COLUMN client_protocol INTEGER NOT NULL DEFAULT 1")
device_columns = {row[1] for row in cur.execute("PRAGMA table_info(devices)").fetchall()}
- if "license_id" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''")
- if "channel" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'")
+ if "license_id" not in device_columns:
+ cur.execute("ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''")
+ if "channel" not in device_columns:
+ cur.execute("ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'")
+ 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 ''")
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("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)",
- [(app_row[0], "stable", "正式版", 1, 10), (app_row[0], "preview", "预览版", 1, 20), (app_row[0], "dev", "开发版", 1, 30)])
+ cur.executemany(
+ "INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)",
+ [
+ (app_row[0], "stable", "正式版", 1, 10),
+ (app_row[0], "preview", "预览版", 1, 20),
+ (app_row[0], "dev", "开发版", 1, 30),
+ ],
+ )
conn.commit()
- print(f"✅ 成功执行tables.sql,创建数据表与测试数据")
+ print("✅ 成功执行tables.sql,创建数据表与测试数据")
else:
- print(f"❌ 错误:当前目录找不到 tables.sql 文件!")
+ print("❌ 错误:当前目录找不到 tables.sql 文件!")
conn.close()
print("===== 数据库初始化完成 =====")
- CRASH_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
- (CRASH_STORAGE_ROOT / "reports").mkdir(parents=True, exist_ok=True)
- (CRASH_STORAGE_ROOT / "symbols").mkdir(parents=True, exist_ok=True)
-
+ ensure_storage_dirs()
start_minio_if_needed()
yield
print("服务进程正常退出")
+
def is_minio_running(endpoint: str) -> bool:
try:
if endpoint.startswith("http://") or endpoint.startswith("https://"):
@@ -177,7 +97,7 @@ def download_minio_binary(target_path: Path) -> str | None:
try:
print(f"MinIO 二进制未找到,尝试下载到 {target_path}")
target_path.parent.mkdir(parents=True, exist_ok=True)
- with urllib.request.urlopen(url, timeout=30) as response, open(target_path, 'wb') as out_file:
+ with urllib.request.urlopen(url, timeout=30) as response, open(target_path, "wb") as out_file:
out_file.write(response.read())
target_path.chmod(0o755)
abs_path = str(target_path.resolve())
@@ -191,7 +111,7 @@ def download_minio_binary(target_path: Path) -> str | None:
def start_minio_if_needed():
if not env_bool("MINIO_AUTO_START", True):
return
- endpoint = os.getenv('MINIO_ENDPOINT')
+ endpoint = os.getenv("MINIO_ENDPOINT")
if not endpoint:
return
@@ -199,9 +119,9 @@ def start_minio_if_needed():
print(f"MinIO 已在 {endpoint} 运行,跳过启动")
return
- minio_cmd = shutil.which('minio')
+ minio_cmd = shutil.which("minio")
if not minio_cmd:
- minio_bin = Path(os.getenv('MINIO_BIN_PATH', 'minio'))
+ minio_bin = Path(os.getenv("MINIO_BIN_PATH", "minio"))
minio_cmd = str(minio_bin.resolve()) if minio_bin.exists() else None
if not minio_cmd and env_bool("MINIO_AUTO_DOWNLOAD", False):
minio_cmd = download_minio_binary(minio_bin)
@@ -214,7 +134,7 @@ def start_minio_if_needed():
minio_address = os.getenv("MINIO_LISTEN_ADDRESS", ":9000")
print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address {minio_address}")
try:
- subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', minio_address], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ subprocess.Popen([minio_cmd, "server", str(data_dir), "--address", minio_address], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(float(os.getenv("MINIO_START_WAIT_SEC", "5")))
if is_minio_running(endpoint):
print("MinIO 已启动成功")
@@ -224,31 +144,11 @@ def start_minio_if_needed():
print(f"启动 MinIO 失败:{err}")
-# Manifest signing key path
-MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "keys/manifest_private_key.pem"))
+app = FastAPI(title=settings.service_title, lifespan=lifespan)
+include_api_routes(app)
-# 创建APP绑定生命周期
-app = FastAPI(title=os.getenv("SERVICE_TITLE", "软件自动升级服务"), lifespan=lifespan)
-
-ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "admin.html")
-
-
-@app.get("/", include_in_schema=False)
-@app.get("/admin.html", include_in_schema=False)
-def 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"})
-
-
-@app.get("/health", include_in_schema=False)
-def health_check():
- return {"status": "ok"}
-
-# 本地上传备份文件目录(MinIO 不通时会保存到这里)
app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static")
-# 跨域配置(给admin前端页面用)
cors_origins = [item.strip() for item in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if item.strip()]
app.add_middleware(
CORSMiddleware,
@@ -258,33 +158,27 @@ app.add_middleware(
allow_headers=["*"],
)
-# 管理后台写操作统一审计;不保存令牌明文和请求正文。
-@app.middleware("http")
-async def admin_audit_middleware(request: Request, call_next):
- should_audit = (request.url.path.startswith("/admin/") and request.method not in ("GET", "HEAD", "OPTIONS")
- and not request.url.path.startswith("/admin/audit-log/"))
- status_code = 500
- try:
- response = await call_next(request); status_code = response.status_code; return response
- finally:
- if should_audit:
- try:
- token = request.headers.get("X-Admin-Token", "")
- conn = db.get_conn(); conn.execute("""INSERT INTO admin_audit_logs
- (actor_hash,action,method,path,target,result,status_code,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?)""",
- (token_digest(token)[:16] if token else "anonymous", request.url.path.removeprefix("/admin/"),
- request.method, request.url.path, request.url.query[:500],
- "success" if 200 <= status_code < 400 else "fail", status_code,
- request.client.host if request.client else "", request.headers.get("user-agent", "")[:300])); conn.commit(); conn.close()
- except Exception as audit_error:
- print("管理员审计日志写入失败:", audit_error)
+app.middleware("http")(admin_audit_middleware)
+
-# ===================== 全局客户端鉴权中间件 =====================
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
- # 后台接口、文档接口、静态回退链接全部跳过客户端token校验
- skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static", "/health", "/api/v1/health", "/api/v1/crash-reports", "/api/v1/symbols")
+ skip_paths = (
+ "/admin",
+ "/assets",
+ "/docs",
+ "/openapi.json",
+ "/redoc",
+ "/static",
+ "/favicon.ico",
+ "/platform-config.json",
+ "/logo.svg",
+ "/health",
+ "/api/v1/health",
+ "/api/v1/crash-reports",
+ "/api/v1/symbols",
+ )
if path == "/" or path.startswith(skip_paths):
response = await call_next(request)
return response
@@ -295,9 +189,10 @@ async def auth_middleware(request: Request, call_next):
if path != "/api/v1/device/issue":
credential = request.headers.get("X-Device-Credential", "")
if not credential:
- return JSONResponse(status_code=401, content={"detail": {
- "error": "device_credential_required", "msg": "缺少设备身份凭证"
- }})
+ return JSONResponse(
+ status_code=401,
+ content={"detail": {"error": "device_credential_required", "msg": "缺少设备身份凭证"}},
+ )
try:
request.state.device_identity = verify_device_credential(credential)
except HTTPException as exc:
@@ -305,1940 +200,8 @@ async def auth_middleware(request: Request, call_next):
response = await call_next(request)
return response
-# ========== 请求体模型 ==========
-class CheckUpdateReq(BaseModel):
- app_id: str
- current_version: str
- channel: str
- client_protocol: int = 1
-
-class DownloadUrlReq(BaseModel):
- app_id: str
- channel: str
- version: str
- version_id: int
- files: list[str]
-
-class ManifestReq(BaseModel):
- app_id: str
- channel: str
- version: str
- version_id: int
-
-class ReportReq(BaseModel):
- app_id: str
- device_id: str
- from_version: str
- to_version: str
- result: str
-
-class ChangeAdminTokenReq(BaseModel):
- new_token: str
-
-class DownloadReportReq(BaseModel):
- app_id: str
- channel: str
- version: str
- result: str
- files: list[dict]
-
-class DeviceIssueReq(BaseModel):
- app_id: str
- channel: str
- license_key: str
- installation_id: str
- machine_hash: str = ""
-
-# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头
-def admin_auth(X_Admin_Token: str = Header("")):
- if not secrets.compare_digest(X_Admin_Token, ADMIN_TOKEN):
- raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
- return True
-
-@app.get("/admin/auth/check")
-def admin_auth_check(auth=Depends(admin_auth)):
- return {"code": 0, "msg": "管理员令牌有效"}
-
-# Manifest signing helpers
-
-def load_manifest_private_key():
- key_path = Path(MANIFEST_PRIVATE_KEY_PATH)
- if not key_path.exists():
- raise FileNotFoundError(f"Manifest private key not found: {key_path}")
- key_data = key_path.read_bytes()
- return load_pem_private_key(key_data, password=None)
-
-
-def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
- 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:
- json_text = canonical_manifest_bytes(manifest_obj)
- private_key = load_manifest_private_key()
- signature = private_key.sign(
- json_text,
- padding.PKCS1v15(),
- hashes.SHA256()
- )
- return base64.b64encode(signature).decode("ascii")
-
-
-def canonical_signed_bytes(obj: dict) -> bytes:
- copy = {k: obj[k] for k in obj if k != "signature"}
- return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
-
-
-def sign_policy(policy_obj: dict) -> tuple[str, str]:
- payload = canonical_signed_bytes(policy_obj)
- signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256())
- return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii")
-
-
-def sign_device_identity(identity: dict) -> tuple[str, str]:
- payload = canonical_signed_bytes(identity)
- signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256())
- return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii")
-
-def verify_device_credential(encoded: str) -> dict:
- try:
- wrapper = json.loads(base64.b64decode(encoded, validate=True).decode("utf-8"))
- identity_text = wrapper["identity_text"]
- signature = base64.b64decode(wrapper["signature"], validate=True)
- load_manifest_private_key().public_key().verify(signature, identity_text.encode(), padding.PKCS1v15(), hashes.SHA256())
- identity = json.loads(identity_text)
- if any(not identity.get(k) for k in ("device_id", "license_id", "app_id", "channel", "installation_id", "credential_seq", "valid_until")):
- raise ValueError("missing field")
- except Exception:
- raise HTTPException(status_code=401, detail={"error":"invalid_device_credential","msg":"设备凭证格式或签名无效"})
- conn = db.get_conn(); row = conn.execute("SELECT * FROM devices WHERE device_id=?", (identity["device_id"],)).fetchone()
- if not row:
- conn.close(); raise HTTPException(status_code=401, detail={"error":"device_not_registered","msg":"设备未登记"})
- if row["disabled"]:
- reason=row["disabled_reason"] or "设备已被管理员禁用"; conn.close()
- raise HTTPException(status_code=403, detail={"error":"device_disabled","msg":reason})
- if row["app_id"] != identity["app_id"] or row["installation_id"] != identity["installation_id"] or int(row["credential_seq"]) != int(identity["credential_seq"]):
- conn.close(); raise HTTPException(status_code=401, detail={"error":"device_credential_revoked","msg":"设备凭证已失效"})
- license_row = conn.execute("SELECT * FROM licenses WHERE license_id=?", (identity.get("license_id", ""),)).fetchone()
- now = datetime.now(timezone.utc)
- try: license_expiry = datetime.fromisoformat((license_row["valid_until"] if license_row else "").replace("Z", "+00:00"))
- except ValueError: license_expiry = now - timedelta(seconds=1)
- if not license_row or license_row["status"] != "active" or license_expiry <= now:
- conn.close(); raise HTTPException(status_code=403, detail={"error":"license_invalid","msg":"授权不存在、已禁用或已过期"})
- if license_row["app_id"] != identity["app_id"] or license_row["channel_code"] != identity.get("channel"):
- conn.close(); raise HTTPException(status_code=403, detail={"error":"license_scope_mismatch","msg":"授权应用或渠道不匹配"})
- conn.execute("UPDATE devices SET last_seen_at=datetime('now') WHERE device_id=?", (identity["device_id"],)); conn.commit(); conn.close()
- return identity
-
-def policy_row_to_dict(row) -> dict:
- if not row:
- return {
- "policy_seq": 1, "force_update": False, "allow_rollback": False,
- "offline_allowed": True, "valid_until": "2099-12-31T23:59:59Z",
- "min_supported_version": "", "disabled_versions": [], "message": ""
- }
- try:
- disabled = json.loads(row["disabled_versions"] or "[]")
- except (TypeError, json.JSONDecodeError):
- disabled = []
- return {
- "policy_seq": int(row["policy_seq"]), "force_update": bool(row["force_update"]),
- "allow_rollback": bool(row["allow_rollback"]), "offline_allowed": bool(row["offline_allowed"]),
- "valid_until": row["valid_until"], "min_supported_version": row["min_supported_version"] or "",
- "disabled_versions": disabled if isinstance(disabled, list) else [], "message": row["message"] or ""
- }
-
-
-def validate_channel_code(code: str) -> bool:
- return bool(code) and len(code) <= 32 and code[0].isalnum() and all(c.isalnum() or c in "_-" for c in code)
-
-def require_channel(conn, app_id: str, channel: str, require_enabled: bool = True):
- row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone()
- if not row: raise HTTPException(status_code=400, detail={"error":"channel_not_found","msg":f"渠道 {channel} 不存在"})
- if require_enabled and not row["enabled"]: raise HTTPException(status_code=403, detail={"error":"channel_disabled","msg":f"渠道 {channel} 已停用"})
- return row
-
-def is_executable_path(path: str) -> bool:
- return path.lower().endswith((".exe", ".dll"))
-
-def version_key(version: str):
- parts = []
- for part in version.split("."):
- digits = "".join(ch for ch in part if ch.isdigit())
- parts.append(int(digits) if digits else 0)
- return tuple(parts)
-
-def normalize_relative_path(raw_path: str) -> str:
- if not isinstance(raw_path, str):
- raise HTTPException(status_code=400, detail="文件相对路径无效")
- normalized = raw_path.replace(chr(92), "/")
- if normalized.startswith("/"):
- raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}")
- normalized = normalized.strip("/")
- if not normalized or chr(0) in normalized:
- raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符")
- path = PurePosixPath(normalized)
- if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
- raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}")
- if path.parts and ":" in path.parts[0]:
- raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}")
- return path.as_posix()
-
-
-def validate_release_path(relative_path: str):
- folded = relative_path.casefold()
- parts = PurePosixPath(folded).parts
- for part in parts[:-1]:
- if should_skip_release_directory(part):
- raise HTTPException(
- status_code=400,
- detail=f"发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: {relative_path}"
- )
-
-
-def should_skip_release_directory(part: str) -> bool:
- folded = part.casefold()
- blocked_directories = {".git", ".vs", "cmakefiles", "debug", "update", "update_temp"}
- return folded in blocked_directories or folded.startswith("build") or folded.endswith("_autogen")
-
-
-def should_skip_release_file(relative_path: str) -> bool:
- folded = relative_path.casefold()
- runtime_protected_files = {
- "client.ini",
- "bootstrap.exe",
- "config/app_config.json",
- "config/local_state.json",
- "config/client_identity.dat",
- "config/version_policy.dat",
- }
- if folded in runtime_protected_files:
- return True
- if folded.startswith("bin/") and folded[4:] in runtime_protected_files:
- return True
- if any(folded.endswith("/" + protected) for protected in runtime_protected_files):
- return True
- if any(folded.endswith("/bin/" + protected) for protected in runtime_protected_files):
- return True
- return folded.endswith((".pdb", ".ilk", ".obj"))
-
-
-def should_skip_release_path(relative_path: str) -> bool:
- folded = relative_path.casefold()
- parts = PurePosixPath(folded).parts
- if any(should_skip_release_directory(part) for part in parts[:-1]):
- return True
- return should_skip_release_file(relative_path)
-
-
-def calc_local_file_sha256(path: Path, chunk_size: int = 1024 * 1024):
- sha = hashlib.sha256()
- total = 0
- try:
- with path.open("rb") as stream:
- while True:
- chunk = stream.read(chunk_size)
- if not chunk:
- break
- sha.update(chunk)
- total += len(chunk)
- except OSError as err:
- raise HTTPException(status_code=500, detail={"error": "local_file_read_failed", "msg": str(err), "path": str(path)})
- return sha.hexdigest(), total
-
-
-def supported_archive_kind(filename: str) -> str:
- name = (filename or "").casefold()
- if name.endswith(".zip"):
- return "zip"
- if name.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tbz2")):
- return "tar"
- if name.endswith(".rar"):
- return "rar"
- return ""
-
-
-def ensure_inside_directory(root: Path, relative_path: str) -> Path:
- target = (root / relative_path).resolve()
- root_resolved = root.resolve()
- if target != root_resolved and root_resolved not in target.parents:
- raise HTTPException(status_code=400, detail=f"压缩包包含不安全路径: {relative_path}")
- return target
-
-
-def check_extracted_publish_limits(total_size: int, file_count: int):
- if PUBLISH_MAX_FILES > 0 and file_count > PUBLISH_MAX_FILES:
- raise HTTPException(status_code=413, detail=f"压缩包解压后文件数量过多:{file_count},当前上限为 {PUBLISH_MAX_FILES}")
- if PUBLISH_MAX_REQUEST_BYTES > 0 and total_size > PUBLISH_MAX_REQUEST_BYTES:
- raise HTTPException(status_code=413, detail=publish_space_detail(
- "publish_extracted_too_large",
- f"压缩包解压后总大小过大,当前上限为 {PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024} MB",
- total_size,
- PUBLISH_MAX_REQUEST_BYTES,
- ))
-
-
-def copy_member_stream_to_path(source, target: Path, size: int | None, limits: dict):
- target.parent.mkdir(parents=True, exist_ok=True)
- total = 0
- with target.open("wb") as output:
- while True:
- chunk = source.read(1024 * 1024)
- if not chunk:
- break
- output.write(chunk)
- total += len(chunk)
- limits["total_size"] += len(chunk)
- check_extracted_publish_limits(limits["total_size"], limits["file_count"])
- if size is not None and size >= 0 and total != size:
- raise HTTPException(status_code=400, detail=f"压缩包文件大小异常: {target.name}")
-
-
-def extract_zip_archive(archive_path: Path, extract_dir: Path):
- limits = {"total_size": 0, "file_count": 0}
- try:
- with zipfile.ZipFile(archive_path) as archive:
- for info in archive.infolist():
- if info.is_dir():
- continue
- rel_path = normalize_relative_path(info.filename)
- if should_skip_release_path(rel_path):
- continue
- target = ensure_inside_directory(extract_dir, rel_path)
- limits["file_count"] += 1
- check_extracted_publish_limits(limits["total_size"] + max(info.file_size, 0), limits["file_count"])
- with archive.open(info, "r") as source:
- copy_member_stream_to_path(source, target, info.file_size, limits)
- except zipfile.BadZipFile:
- raise HTTPException(status_code=400, detail="zip 发布包无效或已损坏")
-
-
-def extract_tar_archive(archive_path: Path, extract_dir: Path):
- limits = {"total_size": 0, "file_count": 0}
- try:
- with tarfile.open(archive_path, mode="r:*") as archive:
- for member in archive.getmembers():
- if not member.isfile():
- continue
- rel_path = normalize_relative_path(member.name)
- if should_skip_release_path(rel_path):
- continue
- target = ensure_inside_directory(extract_dir, rel_path)
- limits["file_count"] += 1
- check_extracted_publish_limits(limits["total_size"] + max(member.size, 0), limits["file_count"])
- source = archive.extractfile(member)
- if source is None:
- raise HTTPException(status_code=400, detail=f"无法读取压缩包文件: {member.name}")
- with source:
- copy_member_stream_to_path(source, target, member.size, limits)
- except tarfile.TarError:
- raise HTTPException(status_code=400, detail="tar 发布包无效或已损坏")
-
-
-def extract_rar_archive(archive_path: Path, extract_dir: Path):
- commands = []
- if shutil.which("bsdtar"):
- commands.append(["bsdtar", "-xf", str(archive_path), "-C", str(extract_dir)])
- if shutil.which("unrar"):
- commands.append(["unrar", "x", "-o+", "-idq", str(archive_path), str(extract_dir) + os.sep])
- if shutil.which("7z"):
- commands.append(["7z", "x", "-y", f"-o{extract_dir}", str(archive_path)])
- if not commands:
- raise HTTPException(
- status_code=400,
- detail="服务器未安装 rar 解压工具,无法解压 .rar。请安装 bsdtar/unrar/7z,或上传 zip/tar.gz/tar.bz2 发布包"
- )
- last_error = ""
- for command in commands:
- try:
- result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=3600)
- except Exception as err:
- last_error = str(err)
- continue
- if result.returncode == 0:
- return
- last_error = (result.stderr or result.stdout or "").strip()
- raise HTTPException(status_code=400, detail=f"rar 发布包解压失败: {last_error or 'unknown error'}")
-
-
-def extract_release_archive(archive_path: Path, filename: str, extract_dir: Path):
- kind = supported_archive_kind(filename)
- if kind == "zip":
- extract_zip_archive(archive_path, extract_dir)
- elif kind == "tar":
- extract_tar_archive(archive_path, extract_dir)
- elif kind == "rar":
- extract_rar_archive(archive_path, extract_dir)
- else:
- raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar")
-
-
-def release_items_from_extracted_dir(extract_dir: Path):
- items = []
- for path in extract_dir.rglob("*"):
- if path.is_symlink() or not path.is_file():
- continue
- rel_path = normalize_relative_path(path.relative_to(extract_dir).as_posix())
- if should_skip_release_path(rel_path):
- continue
- items.append({"path": rel_path, "local_path": path})
- return normalize_release_item_roots(items)
-
-
-def normalize_release_item_roots(items: list[dict]):
- if not items:
- return items
- required_main_key = RELEASE_MAIN_EXECUTABLE.casefold()
- lower_paths = [item["path"].casefold() for item in items]
- if required_main_key in lower_paths:
- return items
- nested_main = next((path for path in lower_paths if path.endswith("/" + required_main_key)), None)
- if not nested_main:
- return items
- top_levels = set()
- for item in items:
- parts = PurePosixPath(item["path"]).parts
- if not parts:
- continue
- top_levels.add(parts[0])
- if len(top_levels) != 1:
- return items
- prefix = next(iter(top_levels)) + "/"
- normalized = []
- for item in items:
- if not item["path"].startswith(prefix):
- return items
- stripped = item["path"][len(prefix):]
- if stripped:
- clone = dict(item)
- clone["path"] = stripped
- normalized.append(clone)
- return normalized
-
-
-def validate_release_items(items: list[dict]):
- if not items:
- raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
- if len(items) > PUBLISH_MAX_FILES:
- raise HTTPException(status_code=413, detail=f"文件数量过多:{len(items)},当前上限为 {PUBLISH_MAX_FILES}")
-
- seen_paths = set()
- filtered = []
- for item in items:
- rel_path = normalize_relative_path(item["path"])
- if should_skip_release_path(rel_path):
- continue
- validate_release_path(rel_path)
- path_key = rel_path.casefold()
- if path_key in seen_paths:
- raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}")
- seen_paths.add(path_key)
- clone = dict(item)
- clone["path"] = rel_path
- filtered.append(clone)
-
- if not filtered:
- raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件")
-
- required_main_key = RELEASE_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}")
- raise HTTPException(status_code=400, detail=f"发布目录中缺少 {RELEASE_MAIN_EXECUTABLE}")
- 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"发布目录中存在嵌套的重复主程序 {nested_main},请使用干净的 Release 输出目录"
- )
- return filtered
-
-# 文件sha256工具
-def calc_sha256(data: bytes):
- sha = hashlib.sha256()
- sha.update(data)
- return sha.hexdigest()
-
-
-def calc_upload_file_sha256(upload, chunk_size: int = 1024 * 1024):
- stream = getattr(upload, "file", None)
- if stream is None:
- raise HTTPException(status_code=400, detail="上传文件对象无效")
- sha = hashlib.sha256()
- total = 0
- try:
- stream.seek(0)
- while True:
- chunk = stream.read(chunk_size)
- if not chunk:
- break
- if isinstance(chunk, str):
- chunk = chunk.encode("utf-8")
- sha.update(chunk)
- total += len(chunk)
- stream.seek(0)
- except OSError as err:
- raise HTTPException(status_code=500, detail={"error": "upload_file_read_failed", "msg": str(err)})
- return sha.hexdigest(), total
-
-
-async def close_upload_safely(upload):
- try:
- close = getattr(upload, "close", None)
- if close:
- result = close()
- if hasattr(result, "__await__"):
- await result
- return
- except Exception as err:
- print(f"close upload temp file failed: {err}")
- stream = getattr(upload, "file", None)
- if stream is not None:
- try:
- stream.close()
- except Exception as err:
- print(f"close upload stream failed: {err}")
-
-
-def publish_space_detail(error: str, msg: str, required_bytes: int, available_bytes: int):
- return {
- "error": error,
- "msg": msg,
- "required_bytes": required_bytes,
- "available_bytes": available_bytes,
- "required_mb": round(required_bytes / 1024 / 1024, 2),
- "available_mb": round(available_bytes / 1024 / 1024, 2),
- }
-
-
-def ensure_publish_storage_space(next_file_size: int):
- required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
- available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
- if available_bytes < required_bytes:
- raise HTTPException(status_code=507, detail=publish_space_detail(
- "storage_space_insufficient",
- "版本存储空间不足,已停止发布。请删除旧版本、清理磁盘或扩容后重试",
- required_bytes,
- available_bytes,
- ))
-
-
-class CrashApiException(Exception):
- def __init__(self, status_code: int, code: str, message: str, retryable: bool = False):
- self.status_code = status_code
- self.code = code
- self.message = message
- self.retryable = retryable
-
-
-@app.exception_handler(CrashApiException)
-async def crash_api_exception_handler(request: Request, exc: CrashApiException):
- return JSONResponse(
- status_code=exc.status_code,
- content={"error": {"code": exc.code, "message": exc.message, "retryable": exc.retryable}},
- )
-
-
-def utc_now_text() -> str:
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
-
-
-def crash_api_error(status_code: int, code: str, message: str, retryable: bool = False):
- raise CrashApiException(status_code, code, message, retryable)
-
-
-def bearer_token_from_request(request: Request) -> str:
- value = request.headers.get("authorization", "")
- if not value.lower().startswith("bearer "):
- crash_api_error(401, "unauthorized", "missing bearer token", False)
- token = value[7:].strip()
- if not token:
- crash_api_error(401, "unauthorized", "missing bearer token", False)
- return token
-
-
-def require_crash_bearer(request: Request, allowed_roles: set[str]) -> str:
- token = bearer_token_from_request(request)
- roles: set[str] = set()
- if CRASH_REPORT_TOKEN and secrets.compare_digest(token, CRASH_REPORT_TOKEN):
- roles.add("crash_report")
- if CRASH_SYMBOL_TOKEN and secrets.compare_digest(token, CRASH_SYMBOL_TOKEN):
- roles.add("symbols")
- if CRASH_ADMIN_TOKEN:
- if secrets.compare_digest(token, CRASH_ADMIN_TOKEN):
- roles.add("admin")
- elif secrets.compare_digest(token, ADMIN_TOKEN):
- roles.add("admin")
- if not roles:
- crash_api_error(401, "unauthorized", "invalid bearer token", False)
- if roles.isdisjoint(allowed_roles):
- crash_api_error(403, "forbidden", "token is not allowed to access this endpoint", False)
- return token
-
-
-def ensure_multipart_request(request: Request, max_bytes: int = CRASH_REQUEST_MAX_BYTES):
- content_type = request.headers.get("content-type", "")
- if "multipart/form-data" not in content_type.lower():
- crash_api_error(415, "unsupported_media_type", "Content-Type must be multipart/form-data", False)
- raw_length = request.headers.get("content-length")
- if raw_length:
- try:
- content_length = int(raw_length)
- except ValueError:
- crash_api_error(400, "metadata_invalid", "Content-Length is invalid", False)
- if content_length > max_bytes:
- crash_api_error(413, "payload_too_large", "request body exceeds limit", False)
-
-
-def is_upload_file(value) -> bool:
- return hasattr(value, "filename") and hasattr(value, "read")
-
-
-async def read_metadata_field(value, max_bytes: int) -> bytes:
- if value is None:
- crash_api_error(400, "metadata_invalid", "metadata is required", False)
- if is_upload_file(value):
- data = bytearray()
- while True:
- chunk = await value.read(64 * 1024)
- if not chunk:
- break
- data.extend(chunk)
- if len(data) > max_bytes:
- crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False)
- return bytes(data)
- data = str(value).encode("utf-8")
- if len(data) > max_bytes:
- crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False)
- return data
-
-
-async def save_upload_limited(upload, target: Path, max_bytes: int, field_name: str) -> dict:
- if not is_upload_file(upload):
- crash_api_error(400, "metadata_invalid", f"{field_name} file is required", False)
- target.parent.mkdir(parents=True, exist_ok=True)
- total = 0
- digest = hashlib.sha256()
- try:
- with open(target, "wb") as out_file:
- while True:
- chunk = await upload.read(1024 * 1024)
- if not chunk:
- break
- total += len(chunk)
- if total > max_bytes:
- crash_api_error(413, "payload_too_large", f"{field_name} exceeds size limit", False)
- digest.update(chunk)
- out_file.write(chunk)
- except Exception:
- if target.exists():
- target.unlink()
- raise
- if total <= 0:
- crash_api_error(400, "metadata_invalid", f"{field_name} file is empty", False)
- return {"sha256": digest.hexdigest(), "size": total, "filename": upload.filename or field_name}
-
-
-def load_json_metadata(raw: bytes, error_code: str = "metadata_invalid") -> dict:
- try:
- obj = json.loads(raw.decode("utf-8"))
- except Exception:
- crash_api_error(400, error_code, "metadata must be valid UTF-8 JSON", False)
- if not isinstance(obj, dict):
- crash_api_error(400, error_code, "metadata must be a JSON object", False)
- return obj
-
-
-def require_metadata_fields(metadata: dict, fields: list[str], error_code: str = "metadata_invalid"):
- for field in fields:
- if metadata.get(field) in (None, ""):
- crash_api_error(400, error_code, f"metadata.{field} is required", False)
- if metadata.get("schemaVersion") != 1:
- crash_api_error(400, error_code, "metadata.schemaVersion must be 1", False)
-
-
-def normalize_declared_sha256(value, field_path: str) -> str:
- if value in (None, ""):
- return ""
- text = str(value).strip().lower()
- if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text):
- crash_api_error(400, "metadata_invalid", f"{field_path} must be a SHA-256 hex string", False)
- return text
-
-
-def declared_file_sha256(metadata: dict, kind: str, filename: str) -> str:
- files = metadata.get("files")
- if not isinstance(files, list):
- crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False)
- for index, item in enumerate(files):
- if not isinstance(item, dict):
- crash_api_error(400, "metadata_invalid", f"metadata.files[{index}] must be an object", False)
- if item.get("kind") == kind or item.get("name") == filename:
- return normalize_declared_sha256(item.get("sha256"), f"metadata.files[{index}].sha256")
- return ""
-
-
-def validate_client_report_id(value: str):
- try:
- uuid.UUID(value)
- except Exception:
- crash_api_error(400, "metadata_invalid", "metadata.clientReportId must be a UUID", False)
-
-
-def safe_storage_component(value, fallback: str = "unknown") -> str:
- text = str(value or fallback).strip()
- cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text)
- return (cleaned[:96] or fallback)
-
-
-def crash_content_hash(metadata_sha256: str, minidump_sha256: str, attachments_sha256: str) -> str:
- payload = json.dumps(
- {"metadata": metadata_sha256, "minidump": minidump_sha256, "attachments": attachments_sha256 or ""},
- sort_keys=True,
- separators=(",", ":"),
- ).encode("utf-8")
- return hashlib.sha256(payload).hexdigest()
-
-
-def generate_prefixed_id(conn, table: str, column: str, prefix: str) -> str:
- day = datetime.now(timezone.utc).strftime("%Y%m%d")
- for _ in range(20):
- value = f"{prefix}_{day}_{secrets.token_hex(4)}"
- if not conn.execute(f"SELECT 1 FROM {table} WHERE {column}=?", (value,)).fetchone():
- return value
- crash_api_error(503, "service_unavailable", "could not allocate report id", True)
-
-
-def cleanup_path(path: Path | None):
- if not path:
- return
- try:
- if path.is_dir():
- shutil.rmtree(path)
- elif path.exists():
- path.unlink()
- except Exception as err:
- print(f"清理临时文件失败: {path}: {err}")
-
-
-def crash_report_response(row, duplicate: bool, status_code: int):
- return JSONResponse(
- status_code=status_code,
- content={
- "reportId": row["report_id"],
- "duplicate": duplicate,
- "status": row["status"],
- "acceptedAtUtc": row["received_at_utc"],
- },
- )
-
-
-def symbol_upload_response(row, duplicate: bool, status_code: int):
- return JSONResponse(
- status_code=status_code,
- content={
- "symbolUploadId": row["symbol_upload_id"],
- "status": row["status"],
- "duplicate": duplicate,
- "acceptedAtUtc": row["received_at_utc"],
- },
- )
-
-
-@app.get("/api/v1/health")
-def crash_health():
- return {
- "status": "ok",
- "service": "simcae-crash-server",
- "version": CRASH_SERVICE_VERSION,
- "timeUtc": utc_now_text(),
- }
-
-
-@app.post("/api/v1/crash-reports")
-async def create_crash_report(request: Request):
- tmp_dir: Path | None = None
- final_dir: Path | None = None
- stored = False
- conn = None
- try:
- require_crash_bearer(request, {"crash_report"})
- ensure_multipart_request(request)
- try:
- form = await request.form()
- except Exception:
- crash_api_error(400, "metadata_invalid", "multipart form is invalid", False)
-
- metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES)
- metadata_sha = hashlib.sha256(metadata_raw).hexdigest()
- metadata = load_json_metadata(metadata_raw)
- require_metadata_fields(
- metadata,
- ["schemaVersion", "clientReportId", "crashTimeUtc", "product", "appVersion", "gitCommit", "buildType", "channel", "platform", "crash", "userConsent", "files"],
- )
- if metadata.get("product") != "SIMCAE":
- crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False)
- if not isinstance(metadata.get("platform"), dict):
- crash_api_error(400, "metadata_invalid", "metadata.platform must be an object", False)
- if not isinstance(metadata.get("crash"), dict):
- crash_api_error(400, "metadata_invalid", "metadata.crash must be an object", False)
- if not isinstance(metadata.get("userConsent"), dict):
- crash_api_error(400, "metadata_invalid", "metadata.userConsent must be an object", False)
-
- client_report_id = str(metadata.get("clientReportId") or "").strip()
- validate_client_report_id(client_report_id)
- idempotency_key = request.headers.get("idempotency-key", "").strip()
- if not idempotency_key:
- crash_api_error(400, "metadata_invalid", "Idempotency-Key header is required", False)
- if idempotency_key != client_report_id:
- crash_api_error(400, "metadata_invalid", "Idempotency-Key must equal metadata.clientReportId", False)
-
- tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"crash_{secrets.token_hex(12)}"
- tmp_dir.mkdir(parents=True, exist_ok=False)
- (tmp_dir / "metadata.json").write_bytes(metadata_raw)
- minidump_info = await save_upload_limited(form.get("minidump"), tmp_dir / "crash.dmp", CRASH_MINIDUMP_MAX_BYTES, "minidump")
- declared_dump_sha = declared_file_sha256(metadata, "minidump", "crash.dmp")
- if declared_dump_sha and declared_dump_sha != minidump_info["sha256"]:
- crash_api_error(400, "file_hash_mismatch", "minidump SHA-256 does not match metadata.files", False)
-
- attachments_info = None
- if form.get("attachments") is not None:
- attachments_info = await save_upload_limited(form.get("attachments"), tmp_dir / "attachments.zip", CRASH_ATTACHMENTS_MAX_BYTES, "attachments")
-
- package_hash = crash_content_hash(metadata_sha, minidump_info["sha256"], attachments_info["sha256"] if attachments_info else "")
- received_at = utc_now_text()
- content_length = int(request.headers.get("content-length") or 0)
- remote_addr = request.client.host if request.client else ""
- server_info = {
- "storageVersion": 1,
- "clientReportId": client_report_id,
- "receivedAtUtc": received_at,
- "remoteAddress": remote_addr,
- "contentLength": content_length,
- }
-
- conn = db.get_conn()
- existing = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone()
- if existing:
- if existing["content_hash"] != package_hash:
- crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False)
- return crash_report_response(existing, True, 200)
-
- report_id = generate_prefixed_id(conn, "crash_reports", "report_id", "srv")
- server_info["reportId"] = report_id
- (tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8")
- final_dir = CRASH_STORAGE_ROOT / "reports" / report_id
- final_dir.parent.mkdir(parents=True, exist_ok=True)
- tmp_dir.rename(final_dir)
- tmp_dir = None
-
- crash_obj = metadata.get("crash") or {}
- conn.execute(
- """
- INSERT INTO crash_reports(
- report_id,client_report_id,content_hash,status,symbolication_status,product,app_version,
- git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,remote_address,
- content_length,metadata_sha256,metadata_size,minidump_sha256,minidump_size,attachments_sha256,
- attachments_size,storage_path,metadata_json,server_json
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
- """,
- (
- report_id,
- client_report_id,
- package_hash,
- "stored",
- "not_started",
- metadata.get("product"),
- metadata.get("appVersion"),
- metadata.get("gitCommit"),
- metadata.get("buildType"),
- metadata.get("channel"),
- str(crash_obj.get("exceptionCode") or ""),
- metadata.get("crashTimeUtc"),
- received_at,
- remote_addr,
- content_length,
- metadata_sha,
- len(metadata_raw),
- minidump_info["sha256"],
- minidump_info["size"],
- attachments_info["sha256"] if attachments_info else "",
- attachments_info["size"] if attachments_info else 0,
- str(final_dir),
- metadata_raw.decode("utf-8", errors="replace"),
- json.dumps(server_info, ensure_ascii=False, separators=(",", ":")),
- ),
- )
- conn.commit()
- stored = True
- row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
- return crash_report_response(row, False, 201)
- except sqlite3.IntegrityError:
- if conn:
- conn.rollback()
- client_report_id = ""
- try:
- metadata = load_json_metadata(metadata_raw)
- client_report_id = str(metadata.get("clientReportId") or "").strip()
- except Exception:
- pass
- if client_report_id:
- existing = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone()
- if existing and existing["content_hash"] == package_hash:
- return crash_report_response(existing, True, 200)
- crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False)
- except CrashApiException:
- if conn:
- conn.rollback()
- raise
- except Exception as err:
- if conn:
- conn.rollback()
- print("崩溃报告上传失败:", err)
- crash_api_error(500, "server_error", "server failed to store crash report", True)
- finally:
- if conn:
- conn.close()
- cleanup_path(tmp_dir)
- if final_dir and not stored:
- cleanup_path(final_dir)
-
-
-@app.get("/api/v1/crash-reports/{report_id}")
-def get_crash_report(report_id: str, request: Request):
- require_crash_bearer(request, {"admin"})
- conn = db.get_conn()
- row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
- conn.close()
- if not row:
- crash_api_error(404, "metadata_invalid", "crash report not found", False)
- return {
- "reportId": row["report_id"],
- "clientReportId": row["client_report_id"],
- "status": row["status"],
- "receivedAtUtc": row["received_at_utc"],
- "symbolicationStatus": row["symbolication_status"],
- "appVersion": row["app_version"],
- "gitCommit": row["git_commit"],
- "product": row["product"],
- "buildType": row["build_type"],
- "channel": row["channel"],
- "exceptionCode": row["exception_code"],
- "files": {
- "metadata": {"name": "metadata.json", "sha256": row["metadata_sha256"], "size": row["metadata_size"]},
- "minidump": {"name": "crash.dmp", "sha256": row["minidump_sha256"], "size": row["minidump_size"]},
- "attachments": {"name": "attachments.zip", "sha256": row["attachments_sha256"], "size": row["attachments_size"]} if row["attachments_size"] else None,
- "server": {"name": "server.json"},
- },
- }
-
-
-@app.get("/api/v1/crash-reports/{report_id}/files/{file_name}")
-def download_crash_report_file(report_id: str, file_name: str, request: Request):
- token = require_crash_bearer(request, {"admin"})
- allowed = {
- "metadata": "metadata.json",
- "metadata.json": "metadata.json",
- "minidump": "crash.dmp",
- "crash.dmp": "crash.dmp",
- "attachments": "attachments.zip",
- "attachments.zip": "attachments.zip",
- "server": "server.json",
- "server.json": "server.json",
- }
- stored_name = allowed.get(file_name)
- if not stored_name:
- crash_api_error(404, "metadata_invalid", "file not found", False)
- conn = db.get_conn()
- row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
- if not row:
- conn.close()
- crash_api_error(404, "metadata_invalid", "crash report not found", False)
- target = Path(row["storage_path"]) / stored_name
- try:
- conn.execute(
- "INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)",
- (report_id, stored_name, token_digest(token)[:16], request.client.host if request.client else "", request.headers.get("user-agent", "")[:300]),
- )
- conn.commit()
- finally:
- conn.close()
- if not target.is_file():
- crash_api_error(404, "metadata_invalid", "file not found", False)
- return FileResponse(target, media_type="application/octet-stream", filename=stored_name)
-
-
-@app.post("/api/v1/symbols")
-async def upload_symbols(request: Request):
- tmp_dir: Path | None = None
- final_dir: Path | None = None
- stored = False
- conn = None
- try:
- require_crash_bearer(request, {"symbols"})
- ensure_multipart_request(request, CRASH_SYMBOLS_MAX_BYTES + CRASH_METADATA_MAX_BYTES)
- try:
- form = await request.form()
- except Exception:
- crash_api_error(400, "metadata_invalid", "multipart form is invalid", False)
- metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES)
- metadata_sha = hashlib.sha256(metadata_raw).hexdigest()
- metadata = load_json_metadata(metadata_raw)
- require_metadata_fields(
- metadata,
- ["schemaVersion", "product", "appVersion", "gitCommit", "buildType", "platform", "toolchain", "createdAtUtc", "files"],
- )
- if metadata.get("product") != "SIMCAE":
- crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False)
- if not isinstance(metadata.get("files"), list):
- crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False)
-
- tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"symbols_{secrets.token_hex(12)}"
- tmp_dir.mkdir(parents=True, exist_ok=False)
- (tmp_dir / "metadata.json").write_bytes(metadata_raw)
- symbols_info = await save_upload_limited(form.get("symbols"), tmp_dir / "symbols.zip", CRASH_SYMBOLS_MAX_BYTES, "symbols")
- identity_payload = json.dumps(
- {
- "product": metadata.get("product"),
- "appVersion": metadata.get("appVersion"),
- "gitCommit": metadata.get("gitCommit"),
- "buildType": metadata.get("buildType"),
- "platform": metadata.get("platform"),
- },
- sort_keys=True,
- separators=(",", ":"),
- )
- identity_hash = hashlib.sha256(identity_payload.encode("utf-8")).hexdigest()
- received_at = utc_now_text()
- conn = db.get_conn()
- existing = conn.execute("SELECT * FROM crash_symbol_uploads WHERE identity_hash=?", (identity_hash,)).fetchone()
- if existing:
- if existing["symbols_sha256"] != symbols_info["sha256"]:
- crash_api_error(409, "idempotency_conflict", "same build identity already has different symbols", False)
- return symbol_upload_response(existing, True, 200)
-
- upload_id = generate_prefixed_id(conn, "crash_symbol_uploads", "symbol_upload_id", "sym")
- final_dir = CRASH_STORAGE_ROOT / "symbols" / safe_storage_component(metadata.get("product")) / safe_storage_component(metadata.get("appVersion")) / safe_storage_component(metadata.get("gitCommit")) / safe_storage_component(metadata.get("buildType")) / safe_storage_component(metadata.get("platform"))
- final_dir.parent.mkdir(parents=True, exist_ok=True)
- if final_dir.exists():
- shutil.rmtree(final_dir)
- server_info = {
- "storageVersion": 1,
- "symbolUploadId": upload_id,
- "receivedAtUtc": received_at,
- "remoteAddress": request.client.host if request.client else "",
- "contentLength": int(request.headers.get("content-length") or 0),
- }
- (tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8")
- tmp_dir.rename(final_dir)
- tmp_dir = None
- conn.execute(
- """
- INSERT INTO crash_symbol_uploads(
- symbol_upload_id,identity_hash,status,product,app_version,git_commit,build_type,platform,toolchain,
- created_at_utc,received_at_utc,metadata_sha256,metadata_size,symbols_sha256,symbols_size,storage_path,metadata_json
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
- """,
- (
- upload_id,
- identity_hash,
- "stored",
- metadata.get("product"),
- metadata.get("appVersion"),
- metadata.get("gitCommit"),
- metadata.get("buildType"),
- metadata.get("platform"),
- metadata.get("toolchain"),
- metadata.get("createdAtUtc"),
- received_at,
- metadata_sha,
- len(metadata_raw),
- symbols_info["sha256"],
- symbols_info["size"],
- str(final_dir),
- metadata_raw.decode("utf-8", errors="replace"),
- ),
- )
- conn.commit()
- stored = True
- row = conn.execute("SELECT * FROM crash_symbol_uploads WHERE symbol_upload_id=?", (upload_id,)).fetchone()
- return symbol_upload_response(row, False, 201)
- except sqlite3.IntegrityError:
- if conn:
- conn.rollback()
- crash_api_error(409, "idempotency_conflict", "same build identity already exists", False)
- except CrashApiException:
- if conn:
- conn.rollback()
- raise
- except Exception as err:
- if conn:
- conn.rollback()
- print("符号包上传失败:", err)
- crash_api_error(500, "server_error", "server failed to store symbols", True)
- finally:
- if conn:
- conn.close()
- cleanup_path(tmp_dir)
- if final_dir and not stored:
- cleanup_path(final_dir)
-
-# ==================== 原有客户端接口 完整保留 ====================
-# 0. 首次设备登记;仅此客户端接口允许没有设备凭证
-@app.post("/api/v1/device/issue")
-async def issue_device(request: Request, body: DeviceIssueReq = Body(...)):
- 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="设备登记参数无效")
- key_hash=token_digest(body.license_key.strip()); now=datetime.now(timezone.utc)
- conn=db.get_conn(); conn.execute("BEGIN IMMEDIATE")
- require_channel(conn, app_id, channel)
- lic=conn.execute("SELECT * FROM licenses WHERE license_key_hash=?",(key_hash,)).fetchone()
- try: expiry=datetime.fromisoformat((lic["valid_until"] if lic else "").replace("Z","+00:00"))
- except ValueError: expiry=now-timedelta(seconds=1)
- if not lic or lic["status"]!="active" or expiry<=now or lic["app_id"]!=app_id or lic["channel_code"]!=channel:
- conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_invalid","msg":"授权密钥无效、已过期或不适用于当前应用/渠道"})
- row=conn.execute("SELECT * FROM devices WHERE app_id=? AND installation_id=?",(app_id,installation_id)).fetchone()
- if row and row["disabled"]: reason=row["disabled_reason"] or "设备已被禁用";conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"device_disabled","msg":reason})
- if row and row["license_id"] not in ("",lic["license_id"]): conn.rollback();conn.close();raise HTTPException(status_code=409,detail="该安装实例已绑定其他授权")
- device_id=row["device_id"] if row else "dev_"+secrets.token_hex(16); seq=int(row["credential_seq"]) if row else 1
- bound=conn.execute("SELECT 1 FROM license_devices WHERE license_id=? AND device_id=?",(lic["license_id"],device_id)).fetchone()
- used=conn.execute("SELECT COUNT(*) FROM license_devices WHERE license_id=?",(lic["license_id"],)).fetchone()[0]
- if not bound and used>=int(lic["max_devices"]): conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_device_limit","msg":"授权设备数量已达到上限"})
- ip=request.client.host if request.client else ""
- if row: conn.execute("UPDATE devices SET machine_hash=?,license_id=?,channel=?,last_seen_at=datetime('now'),last_ip=? WHERE device_id=?",(body.machine_hash.strip().lower(),lic["license_id"],channel,ip,device_id))
- else: conn.execute("INSERT INTO devices(device_id,app_id,installation_id,machine_hash,credential_seq,last_ip,license_id,channel) VALUES(?,?,?,?,?,?,?,?)",(device_id,app_id,installation_id,body.machine_hash.strip().lower(),seq,ip,lic["license_id"],channel))
- conn.execute("INSERT OR IGNORE INTO license_devices(license_id,device_id) VALUES(?,?)",(lic["license_id"],device_id));conn.commit();conn.close()
- identity={"device_id":device_id,"license_id":lic["license_id"],"app_id":app_id,"channel":channel,"installation_id":installation_id,"credential_seq":seq,"issued_at":now.replace(microsecond=0).isoformat().replace("+00:00","Z"),"valid_until":lic["valid_until"],"signature_alg":"RSA-2048-SHA256","key_id":SIGNING_KEY_ID}
- identity_text,signature=sign_device_identity(identity);return {"identity":identity,"identity_text":identity_text,"signature":signature}
-
-# 1. 查询更新接口
-@app.post("/api/v1/update/check")
-async def check_update(request: Request, body: CheckUpdateReq = 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="设备凭证与应用/渠道不匹配")
- print("收到客户端版本检测请求,参数:", body.model_dump())
- app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
- conn = db.get_conn()
- require_channel(conn, app_id, channel)
- ver = conn.execute(
- "SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1", (app_id, channel)
- ).fetchone()
- policy_row = conn.execute(
- "SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)
- ).fetchone()
- conn.close()
-
- settings = policy_row_to_dict(policy_row)
- latest_ver = ver["version"] if ver else ""
- version_id = int(ver["id"]) if ver else 0
- target_protocol = int(ver["client_protocol"] or 1) if ver else 0
- protocol_compatible = not ver or target_protocol >= body.client_protocol
- upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver)
- rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver)
- rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible
- need_update = upgrade_available or rollback_allowed
- disabled = cur_ver in settings["disabled_versions"]
- below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"])
- allow_run = not disabled and not below_min
- force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min
- if disabled:
- action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用"
- elif below_min:
- action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}"
- elif force_update:
- action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用"
- elif rollback_allowed:
- action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}"
- elif rollback_candidate and not protocol_compatible:
- action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级"
- elif rollback_candidate:
- action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级"
- elif upgrade_available:
- action, message = "optional_update", settings["message"] or "发现可用新版本"
- else:
- action, message = "allow", settings["message"] or "当前版本允许使用"
-
- issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
- policy = {
- "app_id": app_id, "channel": channel, "current_version": cur_ver,
- "policy_seq": settings["policy_seq"], "allow_run": allow_run,
- "force_update": force_update, "allow_rollback": settings["allow_rollback"],
- "rollback_targets": [], "offline_allowed": settings["offline_allowed"],
- "issued_at": issued_at, "valid_until": settings["valid_until"],
- "latest_version": latest_ver, "min_supported_version": settings["min_supported_version"],
- "disabled_versions": settings["disabled_versions"], "message": message,
- "signature_alg": "RSA-2048-SHA256", "key_id": SIGNING_KEY_ID
- }
- policy_text, policy_signature = sign_policy(policy)
- policy["signature"] = policy_signature
- return {
- "allow_run": allow_run, "action": action, "message": message,
- "need_update": need_update, "release_available": bool(ver),
- "current_version": cur_ver, "latest_version": latest_ver, "version_id": version_id,
- "force_update": force_update, "allow_rollback": settings["allow_rollback"],
- "rollback": rollback_allowed, "client_protocol": body.client_protocol,
- "target_client_protocol": target_protocol, "protocol_compatible": protocol_compatible,
- "policy_seq": settings["policy_seq"], "policy_valid_until": settings["valid_until"],
- "policy": policy, "policy_text": policy_text, "policy_signature": policy_signature
- }
-
-# 2. 获取文件下载链接
-@app.post("/api/v1/update/download-url")
-async def get_download_url(request: Request, body: DownloadUrlReq = 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="设备凭证与应用/渠道不匹配")
- ver_id = body.version_id
- conn = db.get_conn()
- version_row = conn.execute("SELECT app_id,channel,version FROM versions WHERE id=?", (ver_id,)).fetchone()
- if not version_row or version_row["app_id"] != body.app_id or version_row["channel"] != body.channel or version_row["version"] != body.version:
- conn.close(); raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配")
- rows = conn.execute(
- "SELECT path, sha256, size FROM version_files WHERE version_id=?",
- (ver_id,)
- ).fetchall()
- identity=request.state.device_identity; ip=request.client.host if request.client else ""; ua=request.headers.get("user-agent", "")[:300]
- conn.executemany("""INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent)
- VALUES(?,?,?,?,?,?,?,?,?,?)""", [(identity["device_id"],identity["license_id"],body.app_id,body.version,body.channel,r["path"],r["size"],"authorized",ip,ua) for r in rows])
- conn.commit(); conn.close()
- res = []
- base_path = f"{body.app_id}/{body.channel}/{body.version}/files/"
- for r in rows:
- full_path = base_path + r["path"]
- url = minio_tool.get_url(full_path)
- res.append({
- "path": r["path"],
- "url": url,
- "sha256": r["sha256"],
- "size": r["size"]
- })
- return {"files": res}
-
-@app.post("/api/v1/update/download-report")
-async def report_download(request: Request, body: DownloadReportReq = Body(...)):
- identity=request.state.device_identity
- if identity["app_id"]!=body.app_id or identity["channel"]!=body.channel: raise HTTPException(status_code=403,detail="设备凭证与应用/渠道不匹配")
- if body.result not in ("success","fail"): raise HTTPException(status_code=400,detail="下载结果无效")
- conn=db.get_conn(); ip=request.client.host if request.client else "";ua=request.headers.get("user-agent","")[:300]; values=[]
- for item in body.files[:5000]:
- path=normalize_relative_path(str(item.get("path") or ""));size=max(0,int(item.get("size") or 0));values.append((identity["device_id"],identity["license_id"],body.app_id,body.version,body.channel,path,size,body.result,ip,ua))
- if values: conn.executemany("INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?,?)",values)
- conn.commit();conn.close();return {"code":0,"logged":len(values)}
-
-# 2b. 获取版本 manifest
-@app.post("/api/v1/update/manifest")
-async def get_manifest(request: Request, body: ManifestReq = 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="设备凭证与应用/渠道不匹配")
- ver_id = body.version_id
- conn = db.get_conn()
- ver = conn.execute(
- "SELECT app_id, channel, version, create_time FROM versions WHERE id=?",
- (ver_id,)
- ).fetchone()
- if not ver:
- conn.close()
- raise HTTPException(status_code=404, detail="Version not found")
-
- rows = conn.execute(
- "SELECT path, sha256, size FROM version_files WHERE version_id=?",
- (ver_id,)
- ).fetchall()
- conn.close()
-
- files = []
- for r in rows:
- files.append({
- "path": r["path"],
- "sha256": r["sha256"],
- "size": r["size"],
- "executable": is_executable_path(r["path"])
- })
-
- manifest = {
- "app_id": ver["app_id"],
- "version": ver["version"],
- "channel": ver["channel"],
- "platform": TARGET_PLATFORM,
- "arch": TARGET_ARCH,
- "manifest_seq": int(ver_id),
- "created_at": ver["create_time"],
- "files": files
- }
- manifest_text = canonical_manifest_bytes(manifest).decode("utf-8")
- signature = sign_manifest(manifest)
- manifest["signature"] = signature
- return {
- "manifest": manifest,
- "manifest_text": manifest_text,
- "signature": signature
- }
-
-# 3. 升级结果上报
-@app.post("/api/v1/update/report")
-async def report(request: Request, body: ReportReq = Body(...)):
- identity=request.state.device_identity
- if identity["app_id"] != body.app_id or identity["device_id"] != body.device_id: raise HTTPException(status_code=403, detail="上报身份与设备凭证不匹配")
- conn = db.get_conn()
- insert_sql = """
- INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time)
- VALUES (?,?,?,?,datetime('now'))
- """
- conn.execute(insert_sql, (body.device_id, body.from_version, body.to_version, body.result))
- conn.commit()
- conn.close()
- return {"code": 0, "msg": "上报成功"}
-
-# ==================== 管理后台 Admin 全套接口(统一Header鉴权) ====================
-@app.post("/admin/token/change")
-def admin_change_token(body: ChangeAdminTokenReq, auth=Depends(admin_auth)):
- global ADMIN_TOKEN
- new_token = body.new_token.strip()
- if len(new_token) < 8:
- raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
- if len(new_token) > 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}")
- ADMIN_TOKEN = new_token
- os.environ["ADMIN_TOKEN"] = new_token
- return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"}
-
-# 应用列表 GET
-@app.get("/admin/app/list")
-def admin_get_app_list(auth=Depends(admin_auth)):
- conn = db.get_conn()
- rows = conn.execute("SELECT app_id, app_name FROM apps").fetchall()
- conn.close()
- return {"list": [{"app_id": r["app_id"], "app_name": r["app_name"]} for r in rows]}
-
-# 新增应用 POST json
-@app.post("/admin/app/add")
-def admin_add_app(body: dict, auth=Depends(admin_auth)):
- aid = body["app_id"]
- aname = body["app_name"]
- conn = db.get_conn()
- try:
- conn.execute("INSERT INTO apps(app_id, app_name) VALUES (?, ?)", (aid, aname))
- conn.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)",
- [(aid,"stable","正式版",1,10),(aid,"preview","预览版",1,20),(aid,"dev","开发版",1,30)])
- conn.commit()
- except Exception as e:
- return {"msg": f"创建失败:{str(e)}"}
- conn.close()
- return {"msg": "应用创建成功"}
-
-@app.get("/admin/channel/list")
-def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)):
- conn=db.get_conn();sql="SELECT channel_code,display_name,enabled,sort_order FROM channels WHERE app_id=?"+("" if include_disabled else " AND enabled=1")+" ORDER BY sort_order,channel_code";rows=conn.execute(sql,(app_id,)).fetchall();conn.close();return {"list":[{"channel_code":r["channel_code"],"display_name":r["display_name"],"enabled":bool(r["enabled"]),"sort_order":r["sort_order"]} for r in rows]}
-
-@app.post("/admin/channel/save")
-def admin_channel_save(body: dict, auth=Depends(admin_auth)):
- app_id=str(body.get("app_id") or "").strip();code=str(body.get("channel_code") or "").strip();name=str(body.get("display_name") or "").strip();enabled=bool(body.get("enabled",True));order=int(body.get("sort_order") or 100)
- if not app_id or not validate_channel_code(code) or not name or len(name)>64: raise HTTPException(status_code=400,detail="渠道参数无效;代码仅允许字母、数字、下划线和连字符")
- conn=db.get_conn();
- if not conn.execute("SELECT 1 FROM apps WHERE app_id=?",(app_id,)).fetchone():conn.close();raise HTTPException(status_code=404,detail="应用不存在")
- conn.execute("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?) ON CONFLICT(app_id,channel_code) DO UPDATE SET display_name=excluded.display_name,enabled=excluded.enabled,sort_order=excluded.sort_order,updated_at=datetime('now')",(app_id,code,name,int(enabled),order));conn.commit();conn.close();return {"msg":"渠道已保存"}
-
-# 查询和保存应用渠道策略
-@app.get("/admin/policy")
-def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)):
- conn = db.get_conn()
- row = conn.execute("SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
- conn.close()
- result = policy_row_to_dict(row)
- result.update({"app_id": app_id, "channel": channel})
- return result
-
-
-@app.post("/admin/policy/save")
-def admin_save_policy(body: dict, auth=Depends(admin_auth)):
- app_id = str(body.get("app_id") or "").strip()
- channel = str(body.get("channel") or "").strip()
- if not app_id or not validate_channel_code(channel):
- raise HTTPException(status_code=400, detail="App ID 或渠道无效")
- disabled = body.get("disabled_versions") or []
- if not isinstance(disabled, list) or any(not isinstance(v, str) for v in disabled):
- raise HTTPException(status_code=400, detail="disabled_versions 必须是版本字符串数组")
- valid_until = str(body.get("valid_until") or "").strip()
- try:
- datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
- except ValueError:
- raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
- conn = db.get_conn()
- require_channel(conn, app_id, channel)
- row = conn.execute("SELECT policy_seq FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
- next_seq = (int(row["policy_seq"]) + 1) if row else 1
- values = (next_seq, int(bool(body.get("force_update"))), int(bool(body.get("allow_rollback"))),
- int(bool(body.get("offline_allowed"))), valid_until,
- str(body.get("min_supported_version") or "").strip(),
- json.dumps(disabled, ensure_ascii=False), str(body.get("message") or "").strip(), app_id, channel)
- 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(?,?,?,?,?,?,?,?,?,?)
- 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')
- """, values)
- conn.commit(); conn.close()
- return {"msg": "策略已保存并递增 policy_seq", "policy_seq": next_seq}
-
-
-# 发布新版本(文件上传)
-async def admin_publish_version_locked(request: Request):
- upload_items = []
- archive_upload = None
- archive_temp_dir = None
- try:
- # 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。
- content_length_header = request.headers.get("content-length")
- try:
- content_length = int(content_length_header or 0)
- except ValueError:
- raise HTTPException(status_code=400, detail="无效的 Content-Length")
- if content_length <= 0:
- raise HTTPException(status_code=411, detail="发布请求必须提供 Content-Length")
- if PUBLISH_MAX_REQUEST_BYTES > 0 and content_length > PUBLISH_MAX_REQUEST_BYTES:
- raise HTTPException(status_code=413, detail=publish_space_detail(
- "publish_request_too_large",
- f"发布请求过大,当前上限为 {PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024} MB。请减小发布目录、清理无关文件,或调整 PUBLISH_MAX_REQUEST_MB",
- content_length,
- PUBLISH_MAX_REQUEST_BYTES,
- ))
-
- spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free
- storage_free = shutil.disk_usage(MINIO_DATA_DIR).free
- same_storage_device = os.stat(UPLOAD_SPOOL_DIR).st_dev == os.stat(MINIO_DATA_DIR).st_dev
- if same_storage_device:
- required_bytes = content_length * 2 + UPLOAD_SPACE_RESERVE
- if spool_free < required_bytes:
- raise HTTPException(status_code=507, detail=publish_space_detail(
- "publish_space_insufficient",
- "发布目录上传和版本存储位于同一磁盘,空间不足。请删除旧版本、扩容磁盘,或把 UPLOAD_SPOOL_DIR 放到其他磁盘",
- required_bytes,
- spool_free,
- ))
- else:
- if spool_free < content_length + 64 * 1024 * 1024:
- raise HTTPException(status_code=507, detail=publish_space_detail(
- "upload_temp_space_insufficient",
- "上传临时空间不足,请减小发布包或扩容上传临时目录",
- content_length + 64 * 1024 * 1024,
- spool_free,
- ))
- if storage_free < content_length + UPLOAD_SPACE_RESERVE:
- raise HTTPException(status_code=507, detail=publish_space_detail(
- "storage_space_insufficient",
- "版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本",
- content_length + UPLOAD_SPACE_RESERVE,
- storage_free,
- ))
-
- # 手动解析表单,增加兼容性并便于调试前端上传问题
- try:
- form = await request.form(max_files=PUBLISH_MAX_FILES, max_fields=PUBLISH_MAX_FIELDS)
- except OSError as err:
- if getattr(err, "errno", None) == 28:
- raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试")
- raise
- # 尝试从 form 中获取字段
- app_id = form.get("app_id")
- channel = form.get("channel") or ""
- version = form.get("version")
- try:
- client_protocol = int(form.get("client_protocol") or 2)
- except (TypeError, ValueError):
- raise HTTPException(status_code=400, detail="client_protocol 必须是正整数")
- if client_protocol < 1:
- raise HTTPException(status_code=400, detail="client_protocol 必须是正整数")
-
- # 收集 files(可能是多个同名字段)和 archive(单个压缩包)
- files = []
- archives = []
- if hasattr(form, "getlist"):
- files = form.getlist("files")
- archives = form.getlist("archive")
- else:
- # 兼容性降级:遍历所有表单项
- for k, v in form.multi_items():
- if k == "files":
- files.append(v)
- elif k == "archive":
- archives.append(v)
- files = [item for item in files if hasattr(item, "filename") and item.filename]
- archives = [item for item in archives if hasattr(item, "filename") and item.filename]
-
- # 如果关键信息缺失,返回详细调试信息,方便前端定位问题
- if not app_id or not version:
- headers = {k: v for k, v in request.headers.items()}
- detail = {
- "error": "missing form fields",
- "have_app_id": bool(app_id),
- "have_version": bool(version),
- "content_type": request.headers.get("content-type"),
- "headers": headers,
- "form_keys": list(form.keys()),
- "app_id_value": app_id,
- "version_value": version,
- "app_id_len": len(app_id or ""),
- "version_len": len(version or "")
- }
- raise HTTPException(status_code=422, detail=detail)
-
- if files and archives:
- raise HTTPException(status_code=400, detail="请只选择一种发布方式:软件根目录或压缩发布包")
- if len(archives) > 1:
- raise HTTPException(status_code=400, detail="一次只能上传一个压缩发布包")
- if not files and not archives:
- raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包")
-
- if archives:
- archive_upload = archives[0]
- archive_name = str(archive_upload.filename or "")
- if not supported_archive_kind(archive_name):
- raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar")
- archive_temp_dir = Path(tempfile.mkdtemp(prefix="publish_archive_", dir=UPLOAD_SPOOL_DIR))
- archive_path = archive_temp_dir / ("upload_" + secrets.token_hex(8))
- try:
- archive_upload.file.seek(0)
- with archive_path.open("wb") as target:
- shutil.copyfileobj(archive_upload.file, target, length=1024 * 1024)
- except OSError as err:
- raise HTTPException(status_code=500, detail={"error": "archive_spool_failed", "msg": str(err)})
- 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)
- extracted_total = sum(item["local_path"].stat().st_size for item in upload_items)
- check_extracted_publish_limits(extracted_total, len(upload_items))
- else:
- if len(files) > PUBLISH_MAX_FILES:
- raise HTTPException(status_code=413, detail=f"文件数量过多:{len(files)},当前上限为 {PUBLISH_MAX_FILES}")
-
- relative_paths = form.getlist("relative_paths") if hasattr(form, "getlist") else []
- if relative_paths and len(relative_paths) != len(files):
- raise HTTPException(status_code=400, detail="文件数量与相对路径数量不一致")
-
- raw_items = []
- 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)
-
-
- conn = db.get_conn()
- require_channel(conn, str(app_id), str(channel))
- publish_started = False
- publish_prefix = f"{app_id}/{channel}/{version}/"
- try:
- cur = conn.cursor()
- exists = cur.execute(
- "SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?",
- (app_id, channel, version)
- ).fetchone()
- if exists:
- raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
-
- cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel))
- cur.execute(
- "INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)",
- (app_id, channel, version, client_protocol)
- )
- new_vid = cur.lastrowid
- publish_started = True
-
- for item in upload_items:
- rel_path = item["path"]
- if "upload" in item:
- file = item["upload"]
- f_sha, f_size = calc_upload_file_sha256(file)
- stream = file.file
- else:
- local_path = item["local_path"]
- f_sha, f_size = calc_local_file_sha256(local_path)
- stream = local_path.open("rb")
- try:
- ensure_publish_storage_space(f_size)
- obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}"
- put_result = minio_tool.put_file_stream(obj_path, stream, f_size)
- if put_result.get('storage') == 'local':
- print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}")
- cur.execute(
- "INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)",
- (new_vid, rel_path, f_sha, f_size)
- )
- finally:
- if "local_path" in item:
- stream.close()
-
- conn.commit()
- except sqlite3.IntegrityError as err:
- conn.rollback()
- if publish_started:
- minio_tool.remove_prefix(publish_prefix)
- if 'UNIQUE constraint failed: versions.app_id, versions.channel, versions.version' in str(err):
- raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
- raise HTTPException(status_code=500, detail=str(err))
- except sqlite3.OperationalError as err:
- conn.rollback()
- if publish_started:
- minio_tool.remove_prefix(publish_prefix)
- raise HTTPException(status_code=500, detail={"error": "database locked", "msg": str(err)})
- except HTTPException:
- conn.rollback()
- if publish_started:
- minio_tool.remove_prefix(publish_prefix)
- raise
- except Exception as err:
- conn.rollback()
- if publish_started:
- minio_tool.remove_prefix(publish_prefix)
- if isinstance(err, OSError) and getattr(err, "errno", None) == 28:
- raise HTTPException(status_code=507, detail="发布过程中存储空间耗尽,已清理未完成版本")
- raise HTTPException(status_code=500, detail={"error": "publish_failed", "msg": str(err)})
- finally:
- conn.close()
-
- return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件", "file_count": len(upload_items)}
- finally:
- for item in upload_items:
- upload = item.get("upload") if isinstance(item, dict) else None
- if upload is not None:
- await close_upload_safely(upload)
- if archive_upload is not None:
- await close_upload_safely(archive_upload)
- cleanup_path(archive_temp_dir)
-
-
-@app.post("/admin/publish")
-async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
- if PUBLISH_LOCK.locked():
- raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
- async with PUBLISH_LOCK:
- return await admin_publish_version_locked(request)
-
-@app.post("/admin/version/offline-package")
-def admin_offline_package(body: dict, auth=Depends(admin_auth)):
- version_id = int(body.get("version_id") or 0)
- conn=db.get_conn();ver=conn.execute("SELECT app_id,channel,version,create_time FROM versions WHERE id=?",(version_id,)).fetchone();rows=conn.execute("SELECT path,sha256,size FROM version_files WHERE version_id=? ORDER BY id",(version_id,)).fetchall();conn.close()
- if not ver or not rows: raise HTTPException(status_code=404,detail="版本或版本文件不存在")
- files=[{"path":r["path"],"sha256":r["sha256"],"size":r["size"],"executable":is_executable_path(r["path"])} for r in rows]
- manifest={"app_id":ver["app_id"],"version":ver["version"],"channel":ver["channel"],"platform":TARGET_PLATFORM,"arch":TARGET_ARCH,"manifest_seq":version_id,"created_at":ver["create_time"],"files":files}
- manifest_text=canonical_manifest_bytes(manifest).decode();manifest_signature=sign_manifest(manifest)
- offset=0;entries=[]
- for r in rows: entries.append({"path":r["path"],"offset":offset,"size":r["size"],"sha256":r["sha256"]});offset+=int(r["size"] or 0)
- package={"format":"MUPD0001","app_id":ver["app_id"],"channel":ver["channel"],"version":ver["version"],"version_id":version_id,"created_at":datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00","Z"),"manifest_sha256":hashlib.sha256(manifest_text.encode()).hexdigest(),"payload_size":offset,"files":entries,"signature_alg":"RSA-2048-SHA256","key_id":SIGNING_KEY_ID}
- package_text,package_signature=sign_policy(package)
- wrapper=json.dumps({"package_text":package_text,"package_signature":package_signature,"manifest_text":manifest_text,"manifest_signature":manifest_signature},ensure_ascii=False,separators=(",",":")).encode()
- prefix=f"{ver['app_id']}/{ver['channel']}/{ver['version']}/files/"
- def stream():
- yield b"MUPD0001";yield len(wrapper).to_bytes(8,"little");yield wrapper
- for r in rows:
- yield from minio_tool.iter_file(prefix+r["path"])
- filename=f"{ver['app_id']}_{ver['channel']}_{ver['version']}.upd"
- return StreamingResponse(stream(),media_type="application/octet-stream",headers={"Content-Disposition":f'attachment; filename="{filename}"',"Content-Length":str(16+len(wrapper)+offset)})
-
-# 获取版本列表 GET
-@app.get("/admin/version/list")
-def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
- conn = db.get_conn()
- rows = conn.execute("""
- SELECT id, version, channel, latest, client_protocol, create_time
- FROM versions WHERE app_id=? ORDER BY create_time DESC
- """, (app_id,)).fetchall()
- conn.close()
- out = []
- for r in rows:
- out.append({
- "id": r["id"],
- "version": r["version"],
- "channel": r["channel"],
- "latest": bool(r["latest"]),
- "client_protocol": int(r["client_protocol"] or 1),
- "create_time": r["create_time"]
- })
- return {"list": out}
-
-# 修正历史版本的客户端协议标记
-@app.post("/admin/version/set-protocol")
-def admin_set_version_protocol(body: dict, auth=Depends(admin_auth)):
- version_id = int(body.get("version_id") or 0)
- client_protocol = int(body.get("client_protocol") or 0)
- if version_id < 1 or client_protocol < 1:
- raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数")
- conn = db.get_conn()
- cur = conn.execute("UPDATE versions SET client_protocol=? WHERE id=?", (client_protocol, version_id))
- if cur.rowcount != 1:
- conn.close(); raise HTTPException(status_code=404, detail="版本不存在")
- conn.commit(); conn.close()
- return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol}
-
-
-# 设置指定版本为渠道最新 POST json
-@app.post("/admin/version/set-latest")
-def admin_set_latest(body: dict, auth=Depends(admin_auth)):
- vid = body["version_id"]
- conn = db.get_conn()
- cur = conn.cursor()
- v_info = cur.execute("SELECT app_id, channel FROM versions WHERE id=?", (vid,)).fetchone()
- aid, ch = v_info["app_id"], v_info["channel"]
- cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (aid, ch))
- cur.execute("UPDATE versions SET latest=1 WHERE id=?", (vid,))
- conn.commit()
- conn.close()
- return {"msg": "已切换为渠道最新版本"}
-
-# 删除版本 POST json
-@app.post("/admin/version/delete")
-def admin_delete_version(body: dict, auth=Depends(admin_auth)):
- vid = body["version_id"]
- conn = db.get_conn()
- cur = conn.cursor()
- v_info = cur.execute("SELECT app_id, channel, version, latest FROM versions WHERE id=?", (vid,)).fetchone()
- if not v_info:
- conn.close()
- raise HTTPException(status_code=404, detail="版本不存在")
- aid, ch, ver = v_info["app_id"], v_info["channel"], v_info["version"]
- was_latest = bool(v_info["latest"])
- # 删除minio下该版本全部文件
- prefix = f"{aid}/{ch}/{ver}/files/"
- minio_tool.remove_prefix(prefix)
- # 删除数据库记录
- cur.execute("DELETE FROM version_files WHERE version_id=?", (vid,))
- cur.execute("DELETE FROM versions WHERE id=?", (vid,))
- promoted_version = None
- if was_latest:
- replacement = cur.execute(
- "SELECT id, version FROM versions WHERE app_id=? AND channel=? ORDER BY create_time DESC, id DESC LIMIT 1",
- (aid, ch)
- ).fetchone()
- if replacement:
- cur.execute("UPDATE versions SET latest=1 WHERE id=?", (replacement["id"],))
- promoted_version = replacement["version"]
- conn.commit()
- conn.close()
- return {
- "msg": "版本、云端文件、数据库记录全部删除完成",
- "promoted_latest": promoted_version
- }
-
-# 获取升级日志 GET
-@app.get("/admin/report/list")
-def admin_get_report_log(auth=Depends(admin_auth)):
- conn = db.get_conn()
- rows = conn.execute("""
- SELECT id,device_id,from_ver,to_ver,result,create_time
- FROM upgrade_logs ORDER BY create_time DESC LIMIT 200
- """).fetchall()
- conn.close()
- res = []
- for r in rows:
- res.append({
- "id": r["id"],
- "device_id": r["device_id"],
- "app_id": "",
- "from_version": r["from_ver"],
- "to_version": r["to_ver"],
- "result": r["result"],
- "create_time": r["create_time"]
- })
- return {"list": res}
-
-
-@app.post("/admin/report/delete")
-def admin_delete_report_log(body: dict, auth=Depends(admin_auth)):
- log_id = int(body.get("id") or 0)
- if log_id < 1:
- raise HTTPException(status_code=400, detail="日志 ID 无效")
- conn = db.get_conn()
- cur = conn.execute("DELETE FROM upgrade_logs WHERE id=?", (log_id,))
- conn.commit(); conn.close()
- if cur.rowcount != 1:
- raise HTTPException(status_code=404, detail="升级日志不存在")
- return {"msg": "升级日志已删除", "deleted": cur.rowcount}
-
-
-@app.post("/admin/report/clear")
-def admin_clear_report_logs(auth=Depends(admin_auth)):
- conn = db.get_conn()
- cur = conn.execute("DELETE FROM upgrade_logs")
- conn.commit(); conn.close()
- return {"msg": "升级日志已清空", "deleted": cur.rowcount}
-
-
-@app.get("/admin/runtime-config")
-def admin_runtime_config(auth=Depends(admin_auth)):
- return {
- "release_main_executable": RELEASE_MAIN_EXECUTABLE,
- "target_platform": TARGET_PLATFORM,
- "target_arch": TARGET_ARCH,
- "publish_max_request_bytes": PUBLISH_MAX_REQUEST_BYTES,
- "publish_max_files": PUBLISH_MAX_FILES,
- "publish_max_fields": PUBLISH_MAX_FIELDS,
- }
-
-
-@app.get("/admin/download-log/list")
-def admin_download_log_list(app_id: str="", limit: int=300, auth=Depends(admin_auth)):
- limit=max(1,min(limit,1000));conn=db.get_conn();rows=conn.execute("SELECT * FROM download_logs WHERE app_id=? ORDER BY id DESC LIMIT ?",(app_id,limit)).fetchall() if app_id else conn.execute("SELECT * FROM download_logs ORDER BY id DESC LIMIT ?",(limit,)).fetchall();conn.close();return {"list":[dict(r) for r in rows]}
-
-
-@app.post("/admin/download-log/delete")
-def admin_download_log_delete(body: dict, auth=Depends(admin_auth)):
- log_id = int(body.get("id") or 0)
- if log_id < 1:
- raise HTTPException(status_code=400, detail="日志 ID 无效")
- conn = db.get_conn()
- cur = conn.execute("DELETE FROM download_logs WHERE id=?", (log_id,))
- conn.commit(); conn.close()
- if cur.rowcount != 1:
- raise HTTPException(status_code=404, detail="下载日志不存在")
- return {"msg": "下载日志已删除", "deleted": cur.rowcount}
-
-
-@app.post("/admin/download-log/clear")
-def admin_download_log_clear(body: dict = Body(default={}), auth=Depends(admin_auth)):
- app_id = str((body or {}).get("app_id") or "").strip()
- conn = db.get_conn()
- if app_id:
- cur = conn.execute("DELETE FROM download_logs WHERE app_id=?", (app_id,))
- else:
- cur = conn.execute("DELETE FROM download_logs")
- conn.commit(); conn.close()
- return {"msg": "下载日志已清空", "deleted": cur.rowcount}
-
-
-@app.get("/admin/audit-log/list")
-def admin_audit_log_list(limit: int=300, auth=Depends(admin_auth)):
- conn=db.get_conn();rows=conn.execute("SELECT * FROM admin_audit_logs ORDER BY id DESC LIMIT ?",(max(1,min(limit,1000)),)).fetchall();conn.close();return {"list":[dict(r) for r in rows]}
-
-
-@app.post("/admin/audit-log/delete")
-def admin_audit_log_delete(body: dict, auth=Depends(admin_auth)):
- log_id = int(body.get("id") or 0)
- if log_id < 1:
- raise HTTPException(status_code=400, detail="日志 ID 无效")
- conn = db.get_conn()
- cur = conn.execute("DELETE FROM admin_audit_logs WHERE id=?", (log_id,))
- conn.commit(); conn.close()
- if cur.rowcount != 1:
- raise HTTPException(status_code=404, detail="审计日志不存在")
- return {"msg": "审计日志已删除", "deleted": cur.rowcount}
-
-
-@app.post("/admin/audit-log/clear")
-def admin_audit_log_clear(auth=Depends(admin_auth)):
- conn = db.get_conn()
- cur = conn.execute("DELETE FROM admin_audit_logs")
- conn.commit(); conn.close()
- return {"msg": "审计日志已清空", "deleted": cur.rowcount}
-
-
-@app.get("/admin/crash-report/list")
-def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)):
- limit = max(1, min(limit, 1000))
- conn = db.get_conn()
- rows = conn.execute(
- """
- SELECT
- id,report_id,client_report_id,status,symbolication_status,product,app_version,
- git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,
- remote_address,content_length,metadata_size,minidump_size,attachments_size
- FROM crash_reports
- ORDER BY id DESC
- LIMIT ?
- """,
- (limit,),
- ).fetchall()
- conn.close()
- return {"list": [dict(row) for row in rows]}
-
-
-@app.get("/admin/crash-report/file/{report_id}/{file_name}")
-def admin_crash_report_file(
- report_id: str,
- file_name: str,
- request: Request,
- X_Admin_Token: str = Header(""),
- auth=Depends(admin_auth),
-):
- allowed = {
- "metadata": "metadata.json",
- "metadata.json": "metadata.json",
- "minidump": "crash.dmp",
- "crash.dmp": "crash.dmp",
- "attachments": "attachments.zip",
- "attachments.zip": "attachments.zip",
- "server": "server.json",
- "server.json": "server.json",
- }
- stored_name = allowed.get(file_name)
- if not stored_name:
- raise HTTPException(status_code=404, detail="崩溃报告文件不存在")
-
- conn = db.get_conn()
- row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone()
- if not row:
- conn.close()
- raise HTTPException(status_code=404, detail="崩溃报告不存在")
-
- target = Path(row["storage_path"]) / stored_name
- if not target.is_file():
- conn.close()
- raise HTTPException(status_code=404, detail="崩溃报告文件不存在")
-
- try:
- conn.execute(
- "INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)",
- (
- report_id,
- stored_name,
- token_digest(X_Admin_Token)[:16],
- request.client.host if request.client else "",
- request.headers.get("user-agent", "")[:300],
- ),
- )
- conn.commit()
- finally:
- conn.close()
- return FileResponse(target, media_type="application/octet-stream", filename=stored_name)
-
-
-@app.post("/admin/license/create")
-def admin_license_create(body: dict, auth=Depends(admin_auth)):
- app_id=str(body.get("app_id") or "").strip();channel=str(body.get("channel") or "").strip();customer=str(body.get("customer_name") or "").strip();max_devices=int(body.get("max_devices") or 1);valid_until=str(body.get("valid_until") or "").strip()
- try: expiry=datetime.fromisoformat(valid_until.replace("Z","+00:00"))
- except ValueError: raise HTTPException(status_code=400,detail="valid_until 必须是 ISO-8601 时间")
- if not app_id or not customer or not validate_channel_code(channel) or max_devices<1 or expiry<=datetime.now(timezone.utc): raise HTTPException(status_code=400,detail="授权参数无效")
- license_id="lic_"+secrets.token_hex(12);license_key="MARSCO-"+secrets.token_urlsafe(24);conn=db.get_conn();require_channel(conn,app_id,channel);conn.execute("INSERT INTO licenses(license_id,license_key_hash,customer_name,app_id,channel_code,max_devices,valid_until,status) VALUES(?,?,?,?,?,?,?,'active')",(license_id,token_digest(license_key),customer,app_id,channel,max_devices,valid_until));conn.commit();conn.close();return {"license_id":license_id,"license_key":license_key,"msg":"授权已创建;密钥仅本次返回,请立即保存"}
-
-@app.get("/admin/license/list")
-def admin_license_list(app_id: str="", auth=Depends(admin_auth)):
- conn=db.get_conn();rows=conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l WHERE app_id=? ORDER BY created_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l ORDER BY created_at DESC").fetchall();conn.close();return {"list":[{k:r[k] for k in ("license_id","customer_name","app_id","channel_code","max_devices","valid_until","status","created_at")}|{"used_devices":r["used_devices"]} for r in rows]}
-
-@app.post("/admin/license/set-status")
-def admin_license_set_status(body: dict, auth=Depends(admin_auth)):
- status=str(body.get("status") or "");license_id=str(body.get("license_id") or "");
- if status not in ("active","disabled"): raise HTTPException(status_code=400,detail="授权状态无效")
- conn=db.get_conn();cur=conn.execute("UPDATE licenses SET status=?,updated_at=datetime('now') WHERE license_id=?",(status,license_id));conn.commit();conn.close()
- if cur.rowcount!=1: raise HTTPException(status_code=404,detail="授权不存在")
- return {"msg":"授权状态已更新"}
-
-@app.get("/admin/device/list")
-def admin_device_list(app_id: str = "", auth=Depends(admin_auth)):
- conn=db.get_conn(); rows=conn.execute("SELECT * FROM devices WHERE app_id=? ORDER BY last_seen_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT * FROM devices ORDER BY last_seen_at DESC LIMIT 500").fetchall(); conn.close()
- return {"list":[{"device_id":r["device_id"],"app_id":r["app_id"],"installation_id":r["installation_id"],"disabled":bool(r["disabled"]),"disabled_reason":r["disabled_reason"],"credential_seq":r["credential_seq"],"first_seen_at":r["first_seen_at"],"last_seen_at":r["last_seen_at"],"last_ip":r["last_ip"]} for r in rows]}
-
-@app.post("/admin/device/set-disabled")
-def admin_device_set_disabled(body: dict, auth=Depends(admin_auth)):
- device_id=str(body.get("device_id") or "").strip(); disabled=bool(body.get("disabled")); reason=str(body.get("reason") or "").strip(); conn=db.get_conn(); cur=conn.execute("UPDATE devices SET disabled=?,disabled_reason=? WHERE device_id=?",(int(disabled),reason if disabled else "",device_id))
- if cur.rowcount != 1: conn.close(); raise HTTPException(status_code=404,detail="设备不存在")
- conn.commit(); conn.close(); return {"msg":"设备已禁用" if disabled else "设备已恢复","device_id":device_id}
if __name__ == "__main__":
import uvicorn
+
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)
diff --git a/scripts/ReadMe.txt b/scripts/ReadMe.txt
new file mode 100644
index 0000000..66e4160
--- /dev/null
+++ b/scripts/ReadMe.txt
@@ -0,0 +1,17 @@
+服务端脚本说明
+==============
+
+本目录保存 update-server 的辅助脚本。项目根目录保留 Dockerfile、docker-compose、main.py、requirements.txt 等启动和部署入口,脚本统一放在 scripts/。
+
+脚本列表:
+
+1. package-offline-server.sh
+ 生成服务端 Docker 离线部署包,包含 API 镜像、MinIO 镜像、docker-compose.yml、.env.example、load-images.sh 和签名密钥。
+
+推荐在 update-server 根目录执行:
+
+```bash
+bash ./scripts/package-offline-server.sh \
+ --version 0.1.0 \
+ --output-dir ./dist/SimCAEServerDockerPackage
+```
diff --git a/package-offline-server.sh b/scripts/package-offline-server.sh
similarity index 75%
rename from package-offline-server.sh
rename to scripts/package-offline-server.sh
index 73e7ee9..3dc48ff 100755
--- a/package-offline-server.sh
+++ b/scripts/package-offline-server.sh
@@ -8,11 +8,11 @@ SKIP_PULL=0
usage() {
cat <<'EOF'
-Usage: ./package-offline-server.sh [options]
+Usage: ./scripts/package-offline-server.sh [options]
Options:
--version VERSION Image/package version, default: 0.1.0
- --output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage
+ --output-dir DIR Output package directory, default: ./dist/SimCAEServerDockerPackage
--skip-build Do not build simcae-update-server image
--skip-pull Do not pull MinIO images
-h, --help Show this help
@@ -73,7 +73,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [[ -z "$OUTPUT_DIR" ]]; then
- OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage"
+ OUTPUT_DIR="$PROJECT_ROOT/dist/SimCAEServerDockerPackage"
fi
IMAGE_NAME="simcae-update-server:$VERSION"
@@ -85,14 +85,15 @@ KEYS_DIR="$PACKAGE_DIR/keys"
TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar"
ARCHIVE_FILE="$PACKAGE_DIR.tar.gz"
-require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing."
-require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing."
-require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing."
-require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing."
-require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing."
+require_file "$PROJECT_ROOT/Dockerfile" "Dockerfile is missing."
+require_file "$PROJECT_ROOT/docker-compose.image.yml" "docker-compose.image.yml is missing."
+require_file "$PROJECT_ROOT/.env.docker.example" ".env.docker.example is missing."
+require_file "$PROJECT_ROOT/admin-ui/package.json" "admin-ui/package.json is missing."
+require_file "$PROJECT_ROOT/keys/manifest_private_key.pem" "keys/manifest_private_key.pem is missing."
+require_file "$PROJECT_ROOT/keys/manifest_public_key.pem" "keys/manifest_public_key.pem is missing."
if [[ "$SKIP_BUILD" -eq 0 ]]; then
- docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT"
+ docker build -t "$IMAGE_NAME" -f "$PROJECT_ROOT/Dockerfile" "$PROJECT_ROOT"
fi
if [[ "$SKIP_PULL" -eq 0 ]]; then
@@ -103,19 +104,83 @@ fi
rm -rf "$PACKAGE_DIR"
mkdir -p "$IMAGES_DIR" "$KEYS_DIR"
-cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
-cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example"
-cp "$SCRIPT_DIR/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem"
-cp "$SCRIPT_DIR/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem"
+cp "$PROJECT_ROOT/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
+cp "$PROJECT_ROOT/.env.docker.example" "$PACKAGE_DIR/.env.example"
+cp "$PROJECT_ROOT/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem"
+cp "$PROJECT_ROOT/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem"
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
# SimCAE 服务端 Docker 部署包说明
-这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。
+本文从“维护者打包”到“使用者部署”完整说明 SimCAE 服务端 Docker 包的交付流程。
-如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。
+服务端 Docker 包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、pure-admin(Vue3 + Element Plus)管理后台、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;目标服务器即使不能联网,也可以按本文完成部署。
-## 一、这个包里面有什么
+如果你是维护者,要先按“一、维护者:生成 Docker 离线部署包”打包。如果你已经拿到 `SimCAEServerDockerPackage.tar.gz`,直接从“三、使用者:最小部署流程”开始。
+
+## 一、维护者:生成 Docker 离线部署包
+
+在服务端源码所在机器上执行。通常这台机器是 Ubuntu,并且已经安装 Docker。
+
+进入服务端源码目录:
+
+```bash
+cd update-server
+```
+
+执行打包脚本:
+
+```bash
+bash ./scripts/package-offline-server.sh \
+ --version __VERSION__ \
+ --output-dir ./dist/SimCAEServerDockerPackage
+```
+
+脚本会做这些事情:
+
+```text
+1. 构建 simcae-update-server:__VERSION__ 镜像。
+2. 拉取 MinIO 和 MinIO Client 镜像。
+3. 复制 docker-compose.yml、.env.example、签名密钥和部署说明。
+4. 把 API、MinIO、MinIO Client 三个镜像保存到 images/simcae-server-all-images___VERSION__.tar。
+5. 生成完整目录 dist/SimCAEServerDockerPackage/。
+6. 生成最终交付压缩包 dist/SimCAEServerDockerPackage.tar.gz。
+```
+
+打包完成后,重点确认这两个输出:
+
+```text
+dist/SimCAEServerDockerPackage/
+dist/SimCAEServerDockerPackage.tar.gz
+```
+
+通常只需要把下面这个文件发给部署人员或拷贝到目标服务器:
+
+```text
+dist/SimCAEServerDockerPackage.tar.gz
+```
+
+如果只是重新打包已有镜像,可以使用:
+
+```bash
+bash ./scripts/package-offline-server.sh \
+ --version __VERSION__ \
+ --output-dir ./dist/SimCAEServerDockerPackage \
+ --skip-build
+```
+
+如果打包机器不能联网,但 MinIO 镜像已经提前存在本机,可以使用:
+
+```bash
+bash ./scripts/package-offline-server.sh \
+ --version __VERSION__ \
+ --output-dir ./dist/SimCAEServerDockerPackage \
+ --skip-pull
+```
+
+注意:`keys/manifest_private_key.pem` 会被放进部署包,用于服务端发布版本时签名 Manifest。这个私钥必须保护好,不要公开上传。
+
+## 二、这个包里面有什么
解压后目录结构如下:
@@ -143,7 +208,7 @@ runtime/ 启动后自动生成,保存服务端数据库、上传临
minio_data/ 启动后自动生成,保存升级包文件
```
-## 二、最小部署流程
+## 三、使用者:最小部署流程
先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行:
@@ -226,7 +291,7 @@ MinIO 控制台: http://你的服务器IP:9001/
9001 MinIO 控制台,可选
```
-## 三、.env 配置怎么填
+## 四、.env 配置怎么填
`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。
@@ -236,9 +301,11 @@ MinIO 控制台: http://你的服务器IP:9001/
| --- | --- | --- | --- |
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 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`,所以默认是这个。 |
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
+| `LICENSE_KEY_ENCRYPTION_SECRET` | 必填 | 可以 | 后台授权列表显示 License Key 时使用的加密密钥。正式部署建议修改,并且部署后长期保持不变;如果后续改掉它,旧 License 仍可用于客户端校验,但后台无法再显示旧 License Key 原文。 |
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
@@ -285,7 +352,19 @@ MinIO 控制台: http://你的服务器IP:9001/
| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 |
| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 |
-## 四、客户端配置要同步哪些值
+## 五、客户端配置要同步哪些值
+
+推荐直接使用后台页面生成:
+
+```text
+1. 登录后台
+2. 选择应用和渠道
+3. 如需预置授权,先创建或选择一个 License,页面会自动把可查看的 License 填入“客户端配置生成”
+4. 打开“客户端配置生成”
+5. 点击“生成配套配置”
+6. 点击“复制配置”
+7. 粘贴到客户端 bin/config/app_config.json
+```
客户端 `config/app_config.json` 至少要和服务端保持这两个值一致:
@@ -310,7 +389,7 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
-## 五、发布新版本
+## 六、发布新版本
进入后台后,“发布新版本”支持两种方式:
@@ -327,7 +406,7 @@ RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
-## 六、常用命令
+## 七、常用命令
确认你仍然在 `SimCAEServerDockerPackage` 目录:
@@ -373,7 +452,7 @@ docker compose down
如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。
-## 七、数据、备份和迁移
+## 八、数据、备份和迁移
运行后会生成这些目录或文件:
@@ -396,7 +475,7 @@ docker-compose.yml
不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。
-## 八、常见问题
+## 九、常见问题
### 启动后看不到后端日志
@@ -443,7 +522,7 @@ MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
-## 九、安全提醒
+## 十、安全提醒
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
diff --git a/tables.sql b/tables.sql
index 3d2d80c..f4f21e0 100644
--- a/tables.sql
+++ b/tables.sql
@@ -79,6 +79,7 @@ CREATE TABLE IF NOT EXISTS devices (
-- 8. 软件授权及其设备占用关系
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE,
+ license_key_cipher TEXT NOT NULL DEFAULT '',
customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable',
max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))
登录后显示渠道