feat: 完善服务端工程化、发布流程和自动化测试
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
# 第一阶段:构建 Vue 管理后台。
|
||||||
|
# 最终镜像只需要 admin-ui/dist,不需要 Node、源码依赖和 node_modules。
|
||||||
FROM node:22-alpine AS admin_ui_builder
|
FROM node:22-alpine AS admin_ui_builder
|
||||||
|
|
||||||
WORKDIR /ui
|
WORKDIR /ui
|
||||||
@@ -9,6 +11,8 @@ COPY admin-ui ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# 第二阶段:运行 FastAPI 服务端。
|
||||||
|
# 镜像里只放后端代码、Python 依赖和已经构建好的管理后台静态文件。
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
@@ -28,6 +32,8 @@ COPY main.py db.py minio_tool.py tables.sql ./
|
|||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY --from=admin_ui_builder --chown=updateapp:updateapp /ui/dist ./admin-ui/dist
|
COPY --from=admin_ui_builder --chown=updateapp:updateapp /ui/dist ./admin-ui/dist
|
||||||
|
|
||||||
|
# 运行数据不进镜像,启动时通过 docker-compose 挂载到 /data、/minio_data、/run/secrets/update-keys。
|
||||||
|
# 这样升级文件、SQLite 数据库和私钥可以独立备份,也不会跟镜像版本绑死。
|
||||||
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
||||||
&& chown -R updateapp:updateapp /app /data \
|
&& chown -R updateapp:updateapp /app /data \
|
||||||
&& find /app/admin-ui/dist -type f -exec chmod 644 {} \; \
|
&& find /app/admin-ui/dist -type f -exec chmod 644 {} \; \
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ bash ./scripts/package-offline-server.sh --version 0.1.0 --output-dir ./dist/Sim
|
|||||||
- admin-ui/:新版管理后台前端源码。
|
- admin-ui/:新版管理后台前端源码。
|
||||||
- scripts/package-offline-server.sh:生成服务端 Docker 离线部署包。
|
- scripts/package-offline-server.sh:生成服务端 Docker 离线部署包。
|
||||||
|
|
||||||
|
本地自动化测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd update-server
|
||||||
|
./venv/bin/pip install -r requirements-dev.txt
|
||||||
|
./venv/bin/python3 -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
当前测试会自动创建临时数据库、临时签名密钥和临时存储目录,不会改动本地 `mini.db`、`.env` 或真实上传文件。覆盖范围包括管理员登录/JWT、应用创建、License 创建设备登记、更新检查、发布入库/文件存储回退、崩溃报告上传幂等和管理员查询。
|
||||||
|
|
||||||
本地源码启动:
|
本地源码启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -301,6 +301,8 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
|
|||||||
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
点击“发布版本”后,浏览器会先把文件上传到服务端。上传完成后,服务端会创建后台发布任务,继续执行解压、Manifest 校验、数据库写入和 MinIO 上传;管理页面会轮询并显示任务状态。这样可以减少大版本发布时长时间占用同一个 HTTP 请求的问题。
|
||||||
|
|
||||||
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
|
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
- 运行服务:Uvicorn。
|
- 运行服务:Uvicorn。
|
||||||
- 请求模型和参数校验:FastAPI 自带的 Pydantic 体系。
|
- 请求模型和参数校验:FastAPI 自带的 Pydantic 体系。
|
||||||
- 管理后台前端:GitHub 上的 pure-admin-thin / vue-pure-admin 生态,技术栈是 Vue3、Element Plus、TypeScript、Vite。
|
- 管理后台前端:GitHub 上的 pure-admin-thin / vue-pure-admin 生态,技术栈是 Vue3、Element Plus、TypeScript、Vite。
|
||||||
|
- 前端包管理:统一使用 npm 和 package-lock.json,不再维护 pnpm-lock.yaml。
|
||||||
- 对象存储:MinIO。
|
- 对象存储:MinIO。
|
||||||
|
- 自动化测试:pytest。测试用例会在临时目录里创建隔离数据库、临时 Manifest 私钥、临时上传目录和崩溃报告目录。
|
||||||
|
|
||||||
后端工程化参考方向:
|
后端工程化参考方向:
|
||||||
|
|
||||||
@@ -113,9 +115,35 @@ app/
|
|||||||
- `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。
|
- `app/repositories/`:管理后台已迁移接口的数据库读写层,route 不再直接拼 SQL 或打开数据库连接。
|
||||||
- `app/api/router.py`:统一注册基础路由。
|
- `app/api/router.py`:统一注册基础路由。
|
||||||
- `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。
|
- `main.py`:已经压缩为服务入口,只保留生命周期、MinIO 初始化、静态挂载和全局客户端鉴权。
|
||||||
|
- `tests/`:自动化测试入口,覆盖管理员登录/JWT、License/设备登记、更新检查、版本发布、崩溃报告上传幂等和管理员查询。
|
||||||
|
|
||||||
当前业务接口路径保持不变,避免影响 Launcher、Updater 和已部署管理页面。
|
当前业务接口路径保持不变,避免影响 Launcher、Updater 和已部署管理页面。
|
||||||
|
|
||||||
|
## 自动化测试
|
||||||
|
|
||||||
|
第一次运行测试前安装开发依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd update-server
|
||||||
|
./venv/bin/pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
运行测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python3 -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
当前测试不连接真实 MinIO,也不使用真实 `mini.db`。测试夹具会自动创建临时数据库和临时目录,并生成临时 RSA 私钥,因此适合在提交前快速回归。
|
||||||
|
|
||||||
|
当前覆盖范围:
|
||||||
|
|
||||||
|
- 管理员默认账号初始化、用户名密码登录、JWT 签发。
|
||||||
|
- 应用创建、License 创建、设备登记和设备数占用。
|
||||||
|
- 客户端更新检查和版本策略响应。
|
||||||
|
- 版本发布事务、文件 hash 入库、MinIO 不可用时本地存储回退。
|
||||||
|
- 崩溃报告上传、同一 `clientReportId` 幂等重复上传、管理员查询。
|
||||||
|
|
||||||
## 后续迁移路线
|
## 后续迁移路线
|
||||||
|
|
||||||
后续不建议一次性重写全部后端,而是按下面顺序迁移:
|
后续不建议一次性重写全部后端,而是按下面顺序迁移:
|
||||||
|
|||||||
+5
-7
@@ -1,20 +1,18 @@
|
|||||||
FROM node:20-alpine as build-stage
|
FROM node:22-alpine as build-stage
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
|
||||||
RUN corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
RUN npm config set registry https://registry.npmmirror.com
|
RUN npm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
COPY .npmrc package.json pnpm-lock.yaml ./
|
COPY .npmrc package.json package-lock.json ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:stable-alpine as production-stage
|
FROM nginx:stable-alpine as production-stage
|
||||||
|
|
||||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<h1>vue-pure-admin Lite Edition(no i18n version)</h1>
|
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
**English** | [中文](./README.md)
|
|
||||||
|
|
||||||
## Introduce
|
|
||||||
|
|
||||||
The simplified version is based on the shelf extracted from [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin), which contains main functions and is more suitable for actual project development. The packaged size is introduced globally [element-plus](https://element-plus.org) is still below `2.3MB`, and the full version of the code will be permanently synchronized. After enabling `brotli` compression and `cdn` to replace the local library mode, the package size is less than `350kb`
|
|
||||||
|
|
||||||
## Supporting video
|
|
||||||
|
|
||||||
[Click me to view UI design](https://www.bilibili.com/video/BV17g411T7rq)
|
|
||||||
[Click me to view the rapid development tutorial](https://www.bilibili.com/video/BV1kg411v7QT)
|
|
||||||
|
|
||||||
## Nanny-level documents
|
|
||||||
|
|
||||||
[Click me to view vue-pure-admin documentation](https://pure-admin.cn/)
|
|
||||||
[Click me to view @pureadmin/utils documentation](https://pure-admin-utils.netlify.app)
|
|
||||||
|
|
||||||
## Premium service
|
|
||||||
|
|
||||||
[Click me to view details](https://pure-admin.cn/pages/service/)
|
|
||||||
|
|
||||||
## Preview
|
|
||||||
|
|
||||||
[Click me to view the preview station](https://pure-admin-thin.netlify.app/#/login)
|
|
||||||
|
|
||||||
## Maintainer
|
|
||||||
|
|
||||||
[xiaoxian521](https://github.com/xiaoxian521)
|
|
||||||
|
|
||||||
## ⚠️ Attention
|
|
||||||
|
|
||||||
The Lite version does not accept any issues and prs. If you have any questions, please go to the full version [issues](https://github.com/pure-admin/vue-pure-admin/issues/new/choose) to mention, thank you!
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT © 2020-present, pure-admin](./LICENSE)
|
|
||||||
+22
-31
@@ -1,43 +1,34 @@
|
|||||||
<h1>vue-pure-admin精简版(非国际化版本)</h1>
|
# SimCAE 管理后台
|
||||||
|
|
||||||
[](LICENSE)
|
这个目录是服务端管理后台前端,基于 `pure-admin-thin`、Vue 3、Vite、Element Plus 和 TypeScript 改造。
|
||||||
|
|
||||||
**中文** | [English](./README.en-US.md)
|
## 主要功能
|
||||||
|
|
||||||
## 介绍
|
- 应用、渠道、版本发布和更新策略管理。
|
||||||
|
- License 授权、设备、升级日志、下载日志和审计日志管理。
|
||||||
|
- 崩溃报告接口联调信息展示。
|
||||||
|
- 管理员登录、JWT 会话和角色权限基础能力。
|
||||||
|
|
||||||
精简版是基于 [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin) 提炼出的架子,包含主体功能,更适合实际项目开发,打包后的大小在全局引入 [element-plus](https://element-plus.org) 的情况下仍然低于 `2.3MB`,并且会永久同步完整版的代码。开启 `brotli` 压缩和 `cdn` 替换本地库模式后,打包大小低于 `350kb`
|
## 本地开发
|
||||||
|
|
||||||
## 版本选择
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
当前是非国际化版本,如果您需要国际化版本 [请点击](https://github.com/pure-admin/pure-admin-thin/tree/i18n)
|
## 构建
|
||||||
|
|
||||||
## 配套视频
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
[点我查看 UI 设计](https://www.bilibili.com/video/BV17g411T7rq)
|
构建产物输出到 `dist/`。服务端 Dockerfile 会自动构建并复制 `dist/`,通常不需要手动复制。
|
||||||
[点我查看快速开发教程](https://www.bilibili.com/video/BV1kg411v7QT)
|
|
||||||
|
|
||||||
## 配套保姆级文档
|
## 包管理约定
|
||||||
|
|
||||||
[点我查看 vue-pure-admin 文档](https://pure-admin.cn/)
|
本项目统一使用 `npm` 和 `package-lock.json`。不要再提交 `pnpm-lock.yaml` 或 `yarn.lock`,避免不同包管理器解析出不同依赖版本。
|
||||||
[点我查看 @pureadmin/utils 文档](https://pure-admin-utils.netlify.app)
|
|
||||||
|
|
||||||
## 高级服务
|
## 模板来源
|
||||||
|
|
||||||
[点我查看详情](https://pure-admin.cn/pages/service/)
|
初始工程来自开源后台模板 `pure-admin-thin`。当前仓库只保留 SimCAE 后台需要的页面和运行能力,示例页面不作为业务交付内容。
|
||||||
|
|
||||||
## 预览
|
|
||||||
|
|
||||||
[查看预览](https://pure-admin-thin.netlify.app/#/login)
|
|
||||||
|
|
||||||
## 维护者
|
|
||||||
|
|
||||||
[xiaoxian521](https://github.com/xiaoxian521)
|
|
||||||
|
|
||||||
## ⚠️ 注意
|
|
||||||
|
|
||||||
精简版不接受任何 `issues` 和 `pr`,如果有问题请到完整版 [issues](https://github.com/pure-admin/vue-pure-admin/issues/new/choose) 去提,谢谢!
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
[MIT © 2020-present, pure-admin](./LICENSE)
|
|
||||||
|
|||||||
Generated
-7387
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { formatToken, getToken } from "@/utils/auth";
|
import { formatToken, getToken, removeToken } from "@/utils/auth";
|
||||||
|
|
||||||
export class AdminApiError extends Error {
|
export class AdminApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -70,6 +70,12 @@ export async function adminRequest<T = any>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
removeToken();
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new AdminApiError(
|
throw new AdminApiError(
|
||||||
`HTTP ${response.status}: ${text || response.statusText}`,
|
`HTTP ${response.status}: ${text || response.statusText}`,
|
||||||
response.status,
|
response.status,
|
||||||
@@ -122,6 +128,12 @@ export async function downloadAdminFile(
|
|||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
if (response.status === 401) {
|
||||||
|
removeToken();
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new AdminApiError(
|
throw new AdminApiError(
|
||||||
`HTTP ${response.status}: ${text || response.statusText}`,
|
`HTTP ${response.status}: ${text || response.statusText}`,
|
||||||
response.status,
|
response.status,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type DataInfo,
|
type DataInfo,
|
||||||
userKey,
|
userKey,
|
||||||
|
getToken,
|
||||||
removeToken,
|
removeToken,
|
||||||
multipleTabsKey
|
multipleTabsKey
|
||||||
} from "@/utils/auth";
|
} from "@/utils/auth";
|
||||||
@@ -147,7 +148,8 @@ router.beforeEach((to: ToRouteType, _from, next) => {
|
|||||||
function toCorrectRoute() {
|
function toCorrectRoute() {
|
||||||
whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
|
whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
|
||||||
}
|
}
|
||||||
if (Cookies.get(multipleTabsKey) && userInfo) {
|
const accessToken = getToken()?.accessToken;
|
||||||
|
if (Cookies.get(multipleTabsKey) && userInfo && accessToken) {
|
||||||
// 无权限跳转403页面
|
// 无权限跳转403页面
|
||||||
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
|
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
|
||||||
next({ path: "/error/403" });
|
next({ path: "/error/403" });
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { removeToken, setToken, type DataInfo } from "./auth";
|
|
||||||
import { subBefore, getQueryMap } from "@pureadmin/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 简版前端单点登录,根据实际业务自行编写,平台启动后本地可以跳后面这个链接进行测试 http://localhost:8848/#/permission/page/index?username=sso&roles=admin&accessToken=eyJhbGciOiJIUzUxMiJ9.admin
|
|
||||||
* 划重点:
|
|
||||||
* 判断是否为单点登录,不为则直接返回不再进行任何逻辑处理,下面是单点登录后的逻辑处理
|
|
||||||
* 1.清空本地旧信息;
|
|
||||||
* 2.获取url中的重要参数信息,然后通过 setToken 保存在本地;
|
|
||||||
* 3.删除不需要显示在 url 的参数
|
|
||||||
* 4.使用 window.location.replace 跳转正确页面
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
// 获取 url 中的参数
|
|
||||||
const params = getQueryMap(location.href) as DataInfo<Date>;
|
|
||||||
const must = ["username", "roles", "accessToken"];
|
|
||||||
const mustLength = must.length;
|
|
||||||
if (Object.keys(params).length !== mustLength) return;
|
|
||||||
|
|
||||||
// url 参数满足 must 里的全部值,才判定为单点登录,避免非单点登录时刷新页面无限循环
|
|
||||||
let sso = [];
|
|
||||||
let start = 0;
|
|
||||||
|
|
||||||
while (start < mustLength) {
|
|
||||||
if (Object.keys(params).includes(must[start]) && sso.length <= mustLength) {
|
|
||||||
sso.push(must[start]);
|
|
||||||
} else {
|
|
||||||
sso = [];
|
|
||||||
}
|
|
||||||
start++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sso.length === mustLength) {
|
|
||||||
// 判定为单点登录
|
|
||||||
|
|
||||||
// 清空本地旧信息
|
|
||||||
removeToken();
|
|
||||||
|
|
||||||
// 保存新信息到本地
|
|
||||||
setToken(params);
|
|
||||||
|
|
||||||
// 删除不需要显示在 url 的参数
|
|
||||||
delete params.roles;
|
|
||||||
delete params.accessToken;
|
|
||||||
|
|
||||||
const newUrl = `${location.origin}${location.pathname}${subBefore(
|
|
||||||
location.hash,
|
|
||||||
"?"
|
|
||||||
)}?${JSON.stringify(params)
|
|
||||||
.replace(/["{}]/g, "")
|
|
||||||
.replace(/:/g, "=")
|
|
||||||
.replace(/,/g, "&")}`;
|
|
||||||
|
|
||||||
// 替换历史记录项
|
|
||||||
window.location.replace(newUrl);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { hasAuth, getAuths } from "@/router/utils";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "PermissionButtonRouter"
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<p class="mb-2!">当前拥有的code列表:{{ getAuths() }}</p>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mb-2">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">组件方式判断权限</div>
|
|
||||||
</template>
|
|
||||||
<el-space wrap>
|
|
||||||
<Auth value="permission:btn:add">
|
|
||||||
<el-button plain type="warning">
|
|
||||||
拥有code:'permission:btn:add' 权限可见
|
|
||||||
</el-button>
|
|
||||||
</Auth>
|
|
||||||
<Auth :value="['permission:btn:edit']">
|
|
||||||
<el-button plain type="primary">
|
|
||||||
拥有code:['permission:btn:edit'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</Auth>
|
|
||||||
<Auth
|
|
||||||
:value="[
|
|
||||||
'permission:btn:add',
|
|
||||||
'permission:btn:edit',
|
|
||||||
'permission:btn:delete'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<el-button plain type="danger">
|
|
||||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
|
||||||
'permission:btn:delete'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</Auth>
|
|
||||||
</el-space>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mb-2">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">函数方式判断权限</div>
|
|
||||||
</template>
|
|
||||||
<el-space wrap>
|
|
||||||
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
|
|
||||||
拥有code:'permission:btn:add' 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
|
|
||||||
拥有code:['permission:btn:edit'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="
|
|
||||||
hasAuth([
|
|
||||||
'permission:btn:add',
|
|
||||||
'permission:btn:edit',
|
|
||||||
'permission:btn:delete'
|
|
||||||
])
|
|
||||||
"
|
|
||||||
plain
|
|
||||||
type="danger"
|
|
||||||
>
|
|
||||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
|
||||||
'permission:btn:delete'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</el-space>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
指令方式判断权限(该方式不能动态修改权限)
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-space wrap>
|
|
||||||
<el-button v-auth="'permission:btn:add'" plain type="warning">
|
|
||||||
拥有code:'permission:btn:add' 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button v-auth="['permission:btn:edit']" plain type="primary">
|
|
||||||
拥有code:['permission:btn:edit'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-auth="[
|
|
||||||
'permission:btn:add',
|
|
||||||
'permission:btn:edit',
|
|
||||||
'permission:btn:delete'
|
|
||||||
]"
|
|
||||||
plain
|
|
||||||
type="danger"
|
|
||||||
>
|
|
||||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
|
||||||
'permission:btn:delete'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</el-space>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { hasPerms } from "@/utils/auth";
|
|
||||||
import { useUserStoreHook } from "@/store/modules/user";
|
|
||||||
|
|
||||||
const { permissions } = useUserStoreHook();
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "PermissionButtonLogin"
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<p class="mb-2!">当前拥有的code列表:{{ permissions }}</p>
|
|
||||||
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
|
|
||||||
*:*:* 代表拥有全部按钮级别权限
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mb-2">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">组件方式判断权限</div>
|
|
||||||
</template>
|
|
||||||
<el-space wrap>
|
|
||||||
<Perms value="permission:btn:add">
|
|
||||||
<el-button plain type="warning">
|
|
||||||
拥有code:'permission:btn:add' 权限可见
|
|
||||||
</el-button>
|
|
||||||
</Perms>
|
|
||||||
<Perms :value="['permission:btn:edit']">
|
|
||||||
<el-button plain type="primary">
|
|
||||||
拥有code:['permission:btn:edit'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</Perms>
|
|
||||||
<Perms
|
|
||||||
:value="[
|
|
||||||
'permission:btn:add',
|
|
||||||
'permission:btn:edit',
|
|
||||||
'permission:btn:delete'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<el-button plain type="danger">
|
|
||||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
|
||||||
'permission:btn:delete'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</Perms>
|
|
||||||
</el-space>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mb-2">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">函数方式判断权限</div>
|
|
||||||
</template>
|
|
||||||
<el-space wrap>
|
|
||||||
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
|
|
||||||
拥有code:'permission:btn:add' 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="hasPerms(['permission:btn:edit'])"
|
|
||||||
plain
|
|
||||||
type="primary"
|
|
||||||
>
|
|
||||||
拥有code:['permission:btn:edit'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="
|
|
||||||
hasPerms([
|
|
||||||
'permission:btn:add',
|
|
||||||
'permission:btn:edit',
|
|
||||||
'permission:btn:delete'
|
|
||||||
])
|
|
||||||
"
|
|
||||||
plain
|
|
||||||
type="danger"
|
|
||||||
>
|
|
||||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
|
||||||
'permission:btn:delete'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</el-space>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
指令方式判断权限(该方式不能动态修改权限)
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-space wrap>
|
|
||||||
<el-button v-perms="'permission:btn:add'" plain type="warning">
|
|
||||||
拥有code:'permission:btn:add' 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button v-perms="['permission:btn:edit']" plain type="primary">
|
|
||||||
拥有code:['permission:btn:edit'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-perms="[
|
|
||||||
'permission:btn:add',
|
|
||||||
'permission:btn:edit',
|
|
||||||
'permission:btn:delete'
|
|
||||||
]"
|
|
||||||
plain
|
|
||||||
type="danger"
|
|
||||||
>
|
|
||||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
|
||||||
'permission:btn:delete'] 权限可见
|
|
||||||
</el-button>
|
|
||||||
</el-space>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { initRouter } from "@/router/utils";
|
|
||||||
import { storageLocal } from "@pureadmin/utils";
|
|
||||||
import { type CSSProperties, ref, computed } from "vue";
|
|
||||||
import { useUserStoreHook } from "@/store/modules/user";
|
|
||||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: "PermissionPage"
|
|
||||||
});
|
|
||||||
|
|
||||||
const elStyle = computed((): CSSProperties => {
|
|
||||||
return {
|
|
||||||
width: "85vw",
|
|
||||||
justifyContent: "start"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const username = ref(useUserStoreHook()?.username);
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
value: "admin",
|
|
||||||
label: "管理员角色"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "common",
|
|
||||||
label: "普通角色"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
function onChange() {
|
|
||||||
useUserStoreHook()
|
|
||||||
.loginByUsername({ username: username.value, password: "admin123" })
|
|
||||||
.then(res => {
|
|
||||||
if (res.success) {
|
|
||||||
storageLocal().removeItem("async-routes");
|
|
||||||
usePermissionStoreHook().clearAllCachePage();
|
|
||||||
initRouter();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<p class="mb-2!">
|
|
||||||
模拟后台根据不同角色返回对应路由,观察左侧菜单变化(管理员角色可查看系统管理菜单、普通角色不可查看系统管理菜单)
|
|
||||||
</p>
|
|
||||||
<el-card shadow="never" :style="elStyle">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span>当前角色:{{ username }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-select v-model="username" class="w-[160px]!" @change="onChange">
|
|
||||||
<el-option
|
|
||||||
v-for="item in options"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -8,24 +8,30 @@ import {
|
|||||||
friendlyError
|
friendlyError
|
||||||
} from "@/api/simcae";
|
} from "@/api/simcae";
|
||||||
import { removeToken } from "@/utils/auth";
|
import { removeToken } from "@/utils/auth";
|
||||||
|
import {
|
||||||
|
RELEASE_MANIFEST_NAMES,
|
||||||
|
type UploadEntry,
|
||||||
|
dirname,
|
||||||
|
detectSelectedRoot,
|
||||||
|
executableLeafName,
|
||||||
|
executableNameCandidates,
|
||||||
|
findExecutablePath,
|
||||||
|
isReleaseManifestFile,
|
||||||
|
isSafePublishPath,
|
||||||
|
manifestRuleMatches,
|
||||||
|
parseReleaseManifest,
|
||||||
|
relativePathForFile,
|
||||||
|
releaseMainExecutableForInstall
|
||||||
|
} from "./publishManifest";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "SimCAEConsole"
|
name: "SimCAEConsole"
|
||||||
});
|
});
|
||||||
|
|
||||||
type UploadEntry = { file: File; path: string };
|
|
||||||
type PublishManifestRule = {
|
|
||||||
line: number;
|
|
||||||
raw: string;
|
|
||||||
pattern: string;
|
|
||||||
exclude: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
|
|
||||||
|
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const publishing = ref(false);
|
const publishing = ref(false);
|
||||||
|
const publishJobMessage = ref("");
|
||||||
const apps = ref<any[]>([]);
|
const apps = ref<any[]>([]);
|
||||||
const channels = ref<any[]>([]);
|
const channels = ref<any[]>([]);
|
||||||
const versions = ref<any[]>([]);
|
const versions = ref<any[]>([]);
|
||||||
@@ -204,168 +210,6 @@ const expectedReleaseMainExecutable = computed(() =>
|
|||||||
releaseMainExecutableForInstall(configForm.install_root, configForm.main_executable)
|
releaseMainExecutableForInstall(configForm.install_root, configForm.main_executable)
|
||||||
);
|
);
|
||||||
|
|
||||||
function rawPathForFile(file: File) {
|
|
||||||
const raw = ((file as any).webkitRelativePath || file.name) as string;
|
|
||||||
return raw.replaceAll("\\", "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativePathForFile(file: File) {
|
|
||||||
const raw = rawPathForFile(file);
|
|
||||||
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
||||||
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
|
|
||||||
return parts.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectSelectedRoot(files: File[]) {
|
|
||||||
const first = files[0];
|
|
||||||
if (!first || !(first as any).webkitRelativePath) return "";
|
|
||||||
const parts = rawPathForFile(first).split("/").filter(Boolean);
|
|
||||||
return parts.length > 1 ? parts[0] : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePublishPath(value: string) {
|
|
||||||
let normalized = value.replaceAll("\\", "/").trim();
|
|
||||||
while (normalized.startsWith("./")) normalized = normalized.slice(2);
|
|
||||||
normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeManifestPattern(value: string, rootName: string) {
|
|
||||||
let pattern = normalizePublishPath(value);
|
|
||||||
const root = normalizePublishPath(rootName);
|
|
||||||
if (root) {
|
|
||||||
if (pattern === root) return "*";
|
|
||||||
if (pattern.startsWith(`${root}/`)) {
|
|
||||||
pattern = pattern.slice(root.length + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pattern || "*";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReleaseManifest(text: string, rootName: string): PublishManifestRule[] {
|
|
||||||
return text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((raw, index) => ({ raw, line: index + 1, trimmed: raw.trim() }))
|
|
||||||
.filter(item => item.trimmed && !item.trimmed.startsWith("#"))
|
|
||||||
.map(item => {
|
|
||||||
const exclude = item.trimmed.startsWith("!");
|
|
||||||
const body = exclude ? item.trimmed.slice(1).trim() : item.trimmed;
|
|
||||||
return {
|
|
||||||
line: item.line,
|
|
||||||
raw: item.raw,
|
|
||||||
pattern: normalizeManifestPattern(body, rootName),
|
|
||||||
exclude
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(rule => rule.pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegExp(value: string) {
|
|
||||||
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function manifestRuleMatches(pattern: string, filePath: string) {
|
|
||||||
const normalizedPattern = normalizePublishPath(pattern);
|
|
||||||
const normalizedPath = normalizePublishPath(filePath);
|
|
||||||
if (normalizedPattern === "*") return true;
|
|
||||||
if (normalizedPattern.endsWith("/*")) {
|
|
||||||
const prefix = normalizedPattern.slice(0, -2);
|
|
||||||
return normalizedPath.startsWith(`${prefix}/`);
|
|
||||||
}
|
|
||||||
if (!normalizedPattern.includes("*")) {
|
|
||||||
return normalizedPath === normalizedPattern;
|
|
||||||
}
|
|
||||||
const regex = new RegExp(
|
|
||||||
`^${normalizedPattern.split("*").map(escapeRegExp).join(".*")}$`
|
|
||||||
);
|
|
||||||
return regex.test(normalizedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReleaseManifestFile(path: string) {
|
|
||||||
const normalized = normalizePublishPath(path).toLowerCase();
|
|
||||||
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSafePublishPath(itemPath: string) {
|
|
||||||
const protectedFiles = new Set([
|
|
||||||
"client.ini",
|
|
||||||
"bootstrap.exe",
|
|
||||||
"config/app_config.json",
|
|
||||||
"config/local_state.json",
|
|
||||||
"config/client_identity.dat",
|
|
||||||
"config/version_policy.dat"
|
|
||||||
]);
|
|
||||||
const path = normalizePublishPath(itemPath).toLowerCase();
|
|
||||||
const parts = path.split("/");
|
|
||||||
const blockedDirectory = parts
|
|
||||||
.slice(0, -1)
|
|
||||||
.some(
|
|
||||||
part =>
|
|
||||||
part === ".git" ||
|
|
||||||
part === ".vs" ||
|
|
||||||
part === "debug" ||
|
|
||||||
part === "update" ||
|
|
||||||
part === "update_temp" ||
|
|
||||||
part === "cmakefiles" ||
|
|
||||||
part.startsWith("build") ||
|
|
||||||
part.endsWith("_autogen")
|
|
||||||
);
|
|
||||||
const runtimeFile =
|
|
||||||
protectedFiles.has(path) ||
|
|
||||||
(path.startsWith("bin/") && protectedFiles.has(path.slice(4)));
|
|
||||||
return (
|
|
||||||
!blockedDirectory &&
|
|
||||||
!runtimeFile &&
|
|
||||||
!path.endsWith(".pdb") &&
|
|
||||||
!path.endsWith(".ilk") &&
|
|
||||||
!path.endsWith(".obj")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExecutableName(value: string) {
|
|
||||||
const normalized = normalizePublishPath(value || "SimCAE.exe");
|
|
||||||
if (!normalized) return "SimCAE.exe";
|
|
||||||
const parts = normalized.split("/");
|
|
||||||
const leaf = parts[parts.length - 1];
|
|
||||||
if (!leaf.includes(".")) {
|
|
||||||
parts[parts.length - 1] = `${leaf}.exe`;
|
|
||||||
}
|
|
||||||
return parts.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseMainExecutableForInstall(installRoot: string, mainExecutable: string) {
|
|
||||||
const root = (installRoot || "..").trim();
|
|
||||||
const main = normalizeExecutableName(mainExecutable);
|
|
||||||
if (root === ".") return main;
|
|
||||||
if (main.toLowerCase().startsWith("bin/")) return main;
|
|
||||||
return `bin/${main}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function executableNameCandidates(value: string, fallback: string) {
|
|
||||||
const leaf =
|
|
||||||
normalizePublishPath(value || fallback)
|
|
||||||
.split("/")
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop() || fallback;
|
|
||||||
const lower = leaf.toLowerCase();
|
|
||||||
const candidates = new Set([lower]);
|
|
||||||
if (lower.endsWith(".exe")) {
|
|
||||||
candidates.add(lower.slice(0, -4));
|
|
||||||
} else {
|
|
||||||
candidates.add(`${lower}.exe`);
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function executableLeafName(value: string, fallback: string) {
|
|
||||||
return (
|
|
||||||
normalizePublishPath(value || fallback)
|
|
||||||
.split("/")
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop() || fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function launcherDisplayName() {
|
function launcherDisplayName() {
|
||||||
return executableLeafName(
|
return executableLeafName(
|
||||||
runtime?.default_client_config?.launcher_executable,
|
runtime?.default_client_config?.launcher_executable,
|
||||||
@@ -373,18 +217,6 @@ function launcherDisplayName() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dirname(path: string) {
|
|
||||||
const parts = normalizePublishPath(path).split("/").filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
return parts.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
|
|
||||||
return entries
|
|
||||||
.map(item => normalizePublishPath(item.path))
|
|
||||||
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
function installRootDescription() {
|
function installRootDescription() {
|
||||||
const launcher = launcherDisplayName();
|
const launcher = launcherDisplayName();
|
||||||
return configForm.install_root === "."
|
return configForm.install_root === "."
|
||||||
@@ -465,6 +297,10 @@ function handleError(prefix: string, error: unknown) {
|
|||||||
ElMessage.error(`${prefix}:${friendlyError(error)}`);
|
ElMessage.error(`${prefix}:${friendlyError(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function roleLabel(role: string) {
|
function roleLabel(role: string) {
|
||||||
return adminRoleOptions.value.find(item => item.value === role)?.label || role;
|
return adminRoleOptions.value.find(item => item.value === role)?.label || role;
|
||||||
}
|
}
|
||||||
@@ -637,17 +473,34 @@ async function publishVersion() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
publishing.value = true;
|
publishing.value = true;
|
||||||
|
publishJobMessage.value = "正在上传发布文件...";
|
||||||
try {
|
try {
|
||||||
const result = await adminRequest<any>("/admin/publish", {
|
const job = await adminRequest<any>("/admin/publish/job", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
publishJobMessage.value = job?.message || "发布文件已接收,正在后台处理...";
|
||||||
|
const result = await waitPublishJob(job?.job_id);
|
||||||
ElMessage.success(result?.msg || `发布成功:${publishForm.version}`);
|
ElMessage.success(result?.msg || `发布成功:${publishForm.version}`);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError("发布失败", error);
|
handleError("发布失败", error);
|
||||||
} finally {
|
} finally {
|
||||||
publishing.value = false;
|
publishing.value = false;
|
||||||
|
publishJobMessage.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitPublishJob(jobId: string) {
|
||||||
|
if (!jobId) throw new Error("服务端没有返回发布任务 ID");
|
||||||
|
while (true) {
|
||||||
|
await sleep(1500);
|
||||||
|
const job = await adminRequest<any>(`/admin/publish/job/${encodeURIComponent(jobId)}`);
|
||||||
|
publishJobMessage.value = job?.message || "正在后台发布版本...";
|
||||||
|
if (job?.status === "success") return job.result || { msg: job.message };
|
||||||
|
if (job?.status === "error") {
|
||||||
|
throw new Error(job?.message || "发布任务失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1303,6 +1156,14 @@ test_file/*
|
|||||||
<input v-else type="file" accept=".zip,.tar.gz,.tgz,.tar.bz2,.tbz2,.rar" @change="onArchiveFile" />
|
<input v-else type="file" accept=".zip,.tar.gz,.tgz,.tar.bz2,.tbz2,.rar" @change="onArchiveFile" />
|
||||||
<span>{{ publishSummary }}</span>
|
<span>{{ publishSummary }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="publishJobMessage"
|
||||||
|
class="publish-job-alert"
|
||||||
|
:title="publishJobMessage"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
<el-button type="primary" size="large" :loading="publishing" @click="publishVersion">发布版本</el-button>
|
<el-button type="primary" size="large" :loading="publishing" @click="publishVersion">发布版本</el-button>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -1799,6 +1660,10 @@ test_file/*
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-job-alert {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.install-root-options {
|
.install-root-options {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
export type UploadEntry = { file: File; path: string };
|
||||||
|
|
||||||
|
export type PublishManifestRule = {
|
||||||
|
line: number;
|
||||||
|
raw: string;
|
||||||
|
pattern: string;
|
||||||
|
exclude: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
|
||||||
|
|
||||||
|
export function rawPathForFile(file: File) {
|
||||||
|
const raw = ((file as any).webkitRelativePath || file.name) as string;
|
||||||
|
return raw.replaceAll("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativePathForFile(file: File) {
|
||||||
|
const raw = rawPathForFile(file);
|
||||||
|
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
|
||||||
|
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectSelectedRoot(files: File[]) {
|
||||||
|
const first = files[0];
|
||||||
|
if (!first || !(first as any).webkitRelativePath) return "";
|
||||||
|
const parts = rawPathForFile(first).split("/").filter(Boolean);
|
||||||
|
return parts.length > 1 ? parts[0] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublishPath(value: string) {
|
||||||
|
let normalized = value.replaceAll("\\", "/").trim();
|
||||||
|
while (normalized.startsWith("./")) normalized = normalized.slice(2);
|
||||||
|
normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManifestPattern(value: string, rootName: string) {
|
||||||
|
let pattern = normalizePublishPath(value);
|
||||||
|
const root = normalizePublishPath(rootName);
|
||||||
|
if (root) {
|
||||||
|
if (pattern === root) return "*";
|
||||||
|
if (pattern.startsWith(`${root}/`)) {
|
||||||
|
pattern = pattern.slice(root.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pattern || "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReleaseManifest(text: string, rootName: string): PublishManifestRule[] {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((raw, index) => ({ raw, line: index + 1, trimmed: raw.trim() }))
|
||||||
|
.filter(item => item.trimmed && !item.trimmed.startsWith("#"))
|
||||||
|
.map(item => {
|
||||||
|
const exclude = item.trimmed.startsWith("!");
|
||||||
|
const body = exclude ? item.trimmed.slice(1).trim() : item.trimmed;
|
||||||
|
return {
|
||||||
|
line: item.line,
|
||||||
|
raw: item.raw,
|
||||||
|
pattern: normalizeManifestPattern(body, rootName),
|
||||||
|
exclude
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(rule => rule.pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestRuleMatches(pattern: string, filePath: string) {
|
||||||
|
const normalizedPattern = normalizePublishPath(pattern);
|
||||||
|
const normalizedPath = normalizePublishPath(filePath);
|
||||||
|
if (normalizedPattern === "*") return true;
|
||||||
|
if (normalizedPattern.endsWith("/*")) {
|
||||||
|
const prefix = normalizedPattern.slice(0, -2);
|
||||||
|
return normalizedPath.startsWith(`${prefix}/`);
|
||||||
|
}
|
||||||
|
if (!normalizedPattern.includes("*")) {
|
||||||
|
return normalizedPath === normalizedPattern;
|
||||||
|
}
|
||||||
|
const regex = new RegExp(
|
||||||
|
`^${normalizedPattern.split("*").map(escapeRegExp).join(".*")}$`
|
||||||
|
);
|
||||||
|
return regex.test(normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReleaseManifestFile(path: string) {
|
||||||
|
const normalized = normalizePublishPath(path).toLowerCase();
|
||||||
|
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafePublishPath(itemPath: string) {
|
||||||
|
const protectedFiles = new Set([
|
||||||
|
"client.ini",
|
||||||
|
"bootstrap.exe",
|
||||||
|
"config/app_config.json",
|
||||||
|
"config/local_state.json",
|
||||||
|
"config/client_identity.dat",
|
||||||
|
"config/version_policy.dat"
|
||||||
|
]);
|
||||||
|
const path = normalizePublishPath(itemPath).toLowerCase();
|
||||||
|
const parts = path.split("/");
|
||||||
|
const blockedDirectory = parts
|
||||||
|
.slice(0, -1)
|
||||||
|
.some(
|
||||||
|
part =>
|
||||||
|
part === ".git" ||
|
||||||
|
part === ".vs" ||
|
||||||
|
part === "debug" ||
|
||||||
|
part === "update" ||
|
||||||
|
part === "update_temp" ||
|
||||||
|
part === "cmakefiles" ||
|
||||||
|
part.startsWith("build") ||
|
||||||
|
part.endsWith("_autogen")
|
||||||
|
);
|
||||||
|
const runtimeFile =
|
||||||
|
protectedFiles.has(path) ||
|
||||||
|
(path.startsWith("bin/") && protectedFiles.has(path.slice(4)));
|
||||||
|
return (
|
||||||
|
!blockedDirectory &&
|
||||||
|
!runtimeFile &&
|
||||||
|
!path.endsWith(".pdb") &&
|
||||||
|
!path.endsWith(".ilk") &&
|
||||||
|
!path.endsWith(".obj")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExecutableName(value: string) {
|
||||||
|
const normalized = normalizePublishPath(value || "SimCAE.exe");
|
||||||
|
if (!normalized) return "SimCAE.exe";
|
||||||
|
const parts = normalized.split("/");
|
||||||
|
const leaf = parts[parts.length - 1];
|
||||||
|
if (!leaf.includes(".")) {
|
||||||
|
parts[parts.length - 1] = `${leaf}.exe`;
|
||||||
|
}
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseMainExecutableForInstall(
|
||||||
|
installRoot: string,
|
||||||
|
mainExecutable: string
|
||||||
|
) {
|
||||||
|
const root = (installRoot || "..").trim();
|
||||||
|
const main = normalizeExecutableName(mainExecutable);
|
||||||
|
if (root === ".") return main;
|
||||||
|
if (main.toLowerCase().startsWith("bin/")) return main;
|
||||||
|
return `bin/${main}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executableNameCandidates(value: string, fallback: string) {
|
||||||
|
const leaf =
|
||||||
|
normalizePublishPath(value || fallback)
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop() || fallback;
|
||||||
|
const lower = leaf.toLowerCase();
|
||||||
|
const candidates = new Set([lower]);
|
||||||
|
if (lower.endsWith(".exe")) {
|
||||||
|
candidates.add(lower.slice(0, -4));
|
||||||
|
} else {
|
||||||
|
candidates.add(`${lower}.exe`);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executableLeafName(value: string, fallback: string) {
|
||||||
|
return (
|
||||||
|
normalizePublishPath(value || fallback)
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop() || fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dirname(path: string) {
|
||||||
|
const parts = normalizePublishPath(path).split("/").filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
|
||||||
|
return entries
|
||||||
|
.map(item => normalizePublishPath(item.path))
|
||||||
|
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
@@ -8,11 +10,84 @@ from app.services import publish_service
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
PUBLISH_LOCK = asyncio.Lock()
|
PUBLISH_LOCK = asyncio.Lock()
|
||||||
|
PUBLISH_JOBS: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def public_job(job: dict):
|
||||||
|
return {
|
||||||
|
"job_id": job["job_id"],
|
||||||
|
"status": job["status"],
|
||||||
|
"message": job["message"],
|
||||||
|
"result": job.get("result"),
|
||||||
|
"error": job.get("error"),
|
||||||
|
"created_at": job["created_at"],
|
||||||
|
"updated_at": job["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_publish_job(job_id: str, payload: dict):
|
||||||
|
job = PUBLISH_JOBS[job_id]
|
||||||
|
job.update(status="running", message="正在校验并发布版本", updated_at=utc_now_iso())
|
||||||
|
try:
|
||||||
|
result = await publish_service.publish_payload(payload)
|
||||||
|
job.update(
|
||||||
|
status="success",
|
||||||
|
message=result.get("msg") or "发布完成",
|
||||||
|
result=result,
|
||||||
|
updated_at=utc_now_iso(),
|
||||||
|
)
|
||||||
|
except HTTPException as err:
|
||||||
|
detail = err.detail if isinstance(err.detail, str) else str(err.detail)
|
||||||
|
job.update(status="error", message=detail, error=err.detail, updated_at=utc_now_iso())
|
||||||
|
except Exception as err:
|
||||||
|
job.update(status="error", message=f"发布失败:{err}", error=str(err), updated_at=utc_now_iso())
|
||||||
|
finally:
|
||||||
|
if PUBLISH_LOCK.locked():
|
||||||
|
PUBLISH_LOCK.release()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/publish")
|
@router.post("/admin/publish")
|
||||||
async def admin_publish_version(request: Request, auth=Depends(require_permission("publish:manage"))):
|
async def admin_publish_version(request: Request, auth=Depends(require_permission("publish:manage"))):
|
||||||
|
# 发布版本会写数据库、上传大量文件到 MinIO,并可能持续较长时间。
|
||||||
|
# 同一时间只允许一个发布任务,避免两个版本并发写入导致目录、版本号或文件清单互相污染。
|
||||||
if PUBLISH_LOCK.locked():
|
if PUBLISH_LOCK.locked():
|
||||||
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
||||||
async with PUBLISH_LOCK:
|
async with PUBLISH_LOCK:
|
||||||
return await publish_service.publish_version(request)
|
return await publish_service.publish_version(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/publish/job")
|
||||||
|
async def admin_publish_version_job(request: Request, auth=Depends(require_permission("publish:manage"))):
|
||||||
|
if PUBLISH_LOCK.locked():
|
||||||
|
raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布")
|
||||||
|
await PUBLISH_LOCK.acquire()
|
||||||
|
try:
|
||||||
|
payload = await publish_service.make_background_publish_payload(request)
|
||||||
|
job_id = secrets.token_urlsafe(16)
|
||||||
|
PUBLISH_JOBS[job_id] = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": "queued",
|
||||||
|
"message": "发布文件已接收,等待后台处理",
|
||||||
|
"result": None,
|
||||||
|
"error": None,
|
||||||
|
"created_at": utc_now_iso(),
|
||||||
|
"updated_at": utc_now_iso(),
|
||||||
|
}
|
||||||
|
asyncio.create_task(run_publish_job(job_id, payload))
|
||||||
|
return public_job(PUBLISH_JOBS[job_id])
|
||||||
|
except Exception:
|
||||||
|
if PUBLISH_LOCK.locked():
|
||||||
|
PUBLISH_LOCK.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/publish/job/{job_id}")
|
||||||
|
async def admin_publish_job_status(job_id: str, auth=Depends(require_permission("publish:manage"))):
|
||||||
|
job = PUBLISH_JOBS.get(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="发布任务不存在或服务端已重启")
|
||||||
|
return public_job(job)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ def normalize_relative_path(raw_path: str) -> str:
|
|||||||
|
|
||||||
@router.post("/api/v1/device/issue")
|
@router.post("/api/v1/device/issue")
|
||||||
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
|
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
|
||||||
|
# 设备登记:客户端首次启动时用 License 换取服务端签名的设备凭证。
|
||||||
|
# 后续更新接口依赖这个凭证里的 app_id/channel/device_id/license_id,而不是只相信客户端自报字段。
|
||||||
app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip()
|
app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip()
|
||||||
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
|
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
|
||||||
raise HTTPException(status_code=400, detail="设备登记参数无效")
|
raise HTTPException(status_code=400, detail="设备登记参数无效")
|
||||||
@@ -90,6 +92,8 @@ async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
|
|||||||
|
|
||||||
@router.post("/api/v1/update/check")
|
@router.post("/api/v1/update/check")
|
||||||
async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
|
async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
|
||||||
|
# 更新检查同时返回“能不能运行”和“要不要更新”。
|
||||||
|
# 客户端即使没有新版本,也会拿到签名策略,用于离线启动、禁用版本和防回滚判断。
|
||||||
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
|
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
|
||||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||||
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
|
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
|
||||||
@@ -176,6 +180,8 @@ async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
|
|||||||
|
|
||||||
@router.post("/api/v1/update/download-url")
|
@router.post("/api/v1/update/download-url")
|
||||||
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
|
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
|
||||||
|
# 下载链接不直接暴露 MinIO 永久地址,而是按版本和设备凭证生成短期可用的预签名 URL。
|
||||||
|
# 这样既能让客户端直接下载大文件,又能保留服务端授权控制。
|
||||||
identity = request.state.device_identity
|
identity = request.state.device_identity
|
||||||
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
|
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
|
||||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||||
@@ -213,6 +219,8 @@ async def report_download(request: Request, body: DownloadReportRequest = Body(.
|
|||||||
|
|
||||||
@router.post("/api/v1/update/manifest")
|
@router.post("/api/v1/update/manifest")
|
||||||
async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
|
async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
|
||||||
|
# Manifest 是某个版本的文件清单:路径、大小、SHA256、是否可执行。
|
||||||
|
# 客户端必须先验证服务端签名,再按清单下载和校验文件,防止升级包被篡改。
|
||||||
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
|
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
|
||||||
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
|
||||||
ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
|
ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ def crash_health():
|
|||||||
|
|
||||||
@router.post("/api/v1/crash-reports")
|
@router.post("/api/v1/crash-reports")
|
||||||
async def create_crash_report(request: Request):
|
async def create_crash_report(request: Request):
|
||||||
|
# 崩溃报告接口给 SimCAE/CrashReporter 调用,接收 metadata、minidump 和可选附件。
|
||||||
|
# 具体 token 校验、大小限制、落盘和幂等处理都集中在 crash_api_service。
|
||||||
return await crash_api_service.create_crash_report(request)
|
return await crash_api_service.create_crash_report(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,4 +34,5 @@ def download_crash_report_file(report_id: str, file_name: str, request: Request)
|
|||||||
|
|
||||||
@router.post("/api/v1/symbols")
|
@router.post("/api/v1/symbols")
|
||||||
async def upload_symbols(request: Request):
|
async def upload_symbols(request: Request):
|
||||||
|
# 符号包用于后续解析 minidump 堆栈;它和普通升级文件分开存储、分开授权。
|
||||||
return await crash_api_service.upload_symbols(request)
|
return await crash_api_service.upload_symbols(request)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from app.services.signing_service import MANIFEST_PRIVATE_KEY_PATH
|
|||||||
|
|
||||||
|
|
||||||
def license_key_encryption_material() -> bytes:
|
def license_key_encryption_material() -> bytes:
|
||||||
|
# 数据库只存 License Key 的密文和 hash。
|
||||||
|
# hash 用于客户端提交 License 时快速匹配;密文用于后台列表需要再次展示原始 Key 的场景。
|
||||||
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
|
configured_secret = os.getenv("LICENSE_KEY_ENCRYPTION_SECRET", "").strip()
|
||||||
if configured_secret:
|
if configured_secret:
|
||||||
return configured_secret.encode("utf-8")
|
return configured_secret.encode("utf-8")
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ def should_skip_release_directory(part: str) -> bool:
|
|||||||
|
|
||||||
def should_skip_release_file(relative_path: str) -> bool:
|
def should_skip_release_file(relative_path: str) -> bool:
|
||||||
folded = relative_path.casefold()
|
folded = relative_path.casefold()
|
||||||
|
# 发布包里不能带客户端本机运行态文件。
|
||||||
|
# 这些文件包含 License、设备身份、策略缓存等机器相关数据,应由客户端运行时生成或服务端重新签发。
|
||||||
runtime_protected_files = {
|
runtime_protected_files = {
|
||||||
"client.ini",
|
"client.ini",
|
||||||
"bootstrap.exe",
|
"bootstrap.exe",
|
||||||
@@ -163,6 +165,8 @@ def supported_archive_kind(filename: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
|
def ensure_inside_directory(root: Path, relative_path: str) -> Path:
|
||||||
|
# 解压压缩包时必须防止 Zip Slip / 路径穿越。
|
||||||
|
# 任何 ../ 或绝对路径都不能写出临时解压目录。
|
||||||
target = (root / relative_path).resolve()
|
target = (root / relative_path).resolve()
|
||||||
root_resolved = root.resolve()
|
root_resolved = root.resolve()
|
||||||
if target != root_resolved and root_resolved not in target.parents:
|
if target != root_resolved and root_resolved not in target.parents:
|
||||||
@@ -304,6 +308,8 @@ def release_items_from_extracted_dir(extract_dir: Path, required_main_executable
|
|||||||
|
|
||||||
|
|
||||||
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
|
def normalize_release_item_roots(items: list[dict], required_main_executable: str):
|
||||||
|
# 有些压缩包会多包一层顶级目录,例如 SimCAE-1.0.0/bin/SimCAE.exe。
|
||||||
|
# 如果所有文件都在同一个顶级目录下,并且能找到主程序,就自动剥掉这一层,降低发布者操作成本。
|
||||||
if not items:
|
if not items:
|
||||||
return items
|
return items
|
||||||
required_main_key = required_main_executable.casefold()
|
required_main_key = required_main_executable.casefold()
|
||||||
@@ -417,6 +423,7 @@ async def close_upload_safely(upload):
|
|||||||
|
|
||||||
def ensure_publish_storage_space(next_file_size: int):
|
def ensure_publish_storage_space(next_file_size: int):
|
||||||
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
|
required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE
|
||||||
|
MINIO_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
|
available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free
|
||||||
if available_bytes < required_bytes:
|
if available_bytes < required_bytes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -431,6 +438,8 @@ def ensure_publish_storage_space(next_file_size: int):
|
|||||||
|
|
||||||
|
|
||||||
async def collect_publish_items(request: Request):
|
async def collect_publish_items(request: Request):
|
||||||
|
# 发布入口支持两种方式:浏览器选择 Release 目录,或上传一个压缩发布包。
|
||||||
|
# 这里先做请求大小、临时目录空间和 MinIO 存储空间检查,避免大包把服务器拖死。
|
||||||
content_length_header = request.headers.get("content-length")
|
content_length_header = request.headers.get("content-length")
|
||||||
try:
|
try:
|
||||||
content_length = int(content_length_header or 0)
|
content_length = int(content_length_header or 0)
|
||||||
@@ -595,6 +604,7 @@ async def collect_publish_items(request: Request):
|
|||||||
"upload_items": upload_items,
|
"upload_items": upload_items,
|
||||||
"archive_upload": archive_upload,
|
"archive_upload": archive_upload,
|
||||||
"archive_temp_dir": archive_temp_dir,
|
"archive_temp_dir": archive_temp_dir,
|
||||||
|
"job_temp_dir": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -610,11 +620,55 @@ def cleanup_path(path: Path | None):
|
|||||||
print(f"清理临时文件失败: {path}: {err}")
|
print(f"清理临时文件失败: {path}: {err}")
|
||||||
|
|
||||||
|
|
||||||
async def publish_version(request: Request):
|
async def make_background_publish_payload(request: Request):
|
||||||
|
# 后台发布任务不能继续持有浏览器上传流,所以目录模式要先复制到服务端临时目录。
|
||||||
|
# 压缩包模式在 collect_publish_items 中已经解压为本地文件,只需要保留解压目录到任务结束。
|
||||||
payload = await collect_publish_items(request)
|
payload = await collect_publish_items(request)
|
||||||
|
job_temp_dir = Path(tempfile.mkdtemp(prefix="publish_job_", dir=UPLOAD_SPOOL_DIR))
|
||||||
|
converted_items = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in payload["upload_items"]:
|
||||||
|
if "upload" not in item:
|
||||||
|
converted_items.append(item)
|
||||||
|
continue
|
||||||
|
upload = item["upload"]
|
||||||
|
target = ensure_inside_directory(job_temp_dir, item["path"])
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
upload.file.seek(0)
|
||||||
|
with target.open("wb") as output:
|
||||||
|
shutil.copyfileobj(upload.file, output, length=1024 * 1024)
|
||||||
|
except OSError as err:
|
||||||
|
raise HTTPException(status_code=500, detail={"error": "publish_job_spool_failed", "msg": str(err)})
|
||||||
|
finally:
|
||||||
|
await close_upload_safely(upload)
|
||||||
|
converted_items.append({"path": item["path"], "local_path": target})
|
||||||
|
payload["upload_items"] = converted_items
|
||||||
|
payload["job_temp_dir"] = job_temp_dir
|
||||||
|
payload["archive_upload"] = None
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
for item in payload.get("upload_items", []):
|
||||||
|
upload = item.get("upload") if isinstance(item, dict) else None
|
||||||
|
if upload is not None:
|
||||||
|
await close_upload_safely(upload)
|
||||||
|
archive_upload = payload.get("archive_upload")
|
||||||
|
if archive_upload is not None:
|
||||||
|
await close_upload_safely(archive_upload)
|
||||||
|
cleanup_path(payload.get("archive_temp_dir"))
|
||||||
|
cleanup_path(job_temp_dir)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_payload(payload: dict):
|
||||||
|
# 完整发布事务:
|
||||||
|
# 1. 使用已收集的发布文件;2. 创建版本记录;3. 上传文件到 MinIO;4. 写入文件 hash/size。
|
||||||
|
# 任何一步失败都会回滚数据库,并删除已经上传的对象前缀,避免出现“半个版本”。
|
||||||
upload_items = payload["upload_items"]
|
upload_items = payload["upload_items"]
|
||||||
archive_upload = payload["archive_upload"]
|
archive_upload = payload["archive_upload"]
|
||||||
archive_temp_dir = payload["archive_temp_dir"]
|
archive_temp_dir = payload["archive_temp_dir"]
|
||||||
|
job_temp_dir = payload.get("job_temp_dir")
|
||||||
app_id = payload["app_id"]
|
app_id = payload["app_id"]
|
||||||
channel = payload["channel"]
|
channel = payload["channel"]
|
||||||
version = payload["version"]
|
version = payload["version"]
|
||||||
@@ -689,3 +743,9 @@ async def publish_version(request: Request):
|
|||||||
if archive_upload is not None:
|
if archive_upload is not None:
|
||||||
await close_upload_safely(archive_upload)
|
await close_upload_safely(archive_upload)
|
||||||
cleanup_path(archive_temp_dir)
|
cleanup_path(archive_temp_dir)
|
||||||
|
cleanup_path(job_temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_version(request: Request):
|
||||||
|
payload = await collect_publish_items(request)
|
||||||
|
return await publish_payload(payload)
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ def load_manifest_private_key():
|
|||||||
|
|
||||||
|
|
||||||
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
|
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
|
||||||
|
# 签名之前必须先规范化 JSON:固定字段顺序、去掉空格、排除 signature 字段。
|
||||||
|
# 否则同一份 Manifest 在不同环境下序列化结果不同,会导致客户端验签失败。
|
||||||
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
|
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
|
||||||
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def sign_manifest(manifest_obj: dict) -> str:
|
def sign_manifest(manifest_obj: dict) -> str:
|
||||||
|
# 服务端只保存私钥,客户端只分发公钥。
|
||||||
|
# 客户端能验证 Manifest 确实由服务端签发,但无法伪造新的签名。
|
||||||
json_text = canonical_manifest_bytes(manifest_obj)
|
json_text = canonical_manifest_bytes(manifest_obj)
|
||||||
private_key = load_manifest_private_key()
|
private_key = load_manifest_private_key()
|
||||||
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
|
signature = private_key.sign(json_text, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services:
|
|||||||
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
# 离线部署包使用已经 docker load 导入的镜像,不在目标服务器重新 build。
|
||||||
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
|
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||||
@@ -57,6 +58,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
# runtime 保存 SQLite、上传缓存和崩溃报告等运行数据;keys 只读挂载,避免私钥被容器误改。
|
||||||
- ./.env:/app/.env
|
- ./.env:/app/.env
|
||||||
- ./runtime:/data
|
- ./runtime:/data
|
||||||
- ./minio_data:/minio_data:ro
|
- ./minio_data:/minio_data:ro
|
||||||
|
|||||||
+4
-2
@@ -34,9 +34,10 @@ services:
|
|||||||
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
# 开发/源码部署使用 build,从当前源码构建镜像;正式离线交付通常使用 docker-compose.image.yml 生成的包。
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||||
env_file:
|
env_file:
|
||||||
@@ -59,6 +60,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
# 数据卷和密钥挂载在容器外,重建镜像或升级容器时不会丢失历史版本和授权数据。
|
||||||
- ./.env:/app/.env
|
- ./.env:/app/.env
|
||||||
- ./runtime:/data
|
- ./runtime:/data
|
||||||
- ./minio_data:/minio_data:ro
|
- ./minio_data:/minio_data:ro
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
pytest>=8,<9
|
||||||
@@ -411,6 +411,8 @@ CLIENT_API_TOKEN=SimCAEClientToken2026
|
|||||||
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
点击“发布版本”后,浏览器会先把文件上传到服务端。上传完成后,服务端会创建后台发布任务,继续执行解压、Manifest 校验、数据库写入和 MinIO 上传;管理页面会轮询并显示任务状态。这样可以减少大版本发布时长时间占用同一个 HTTP 请求的问题。
|
||||||
|
|
||||||
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
|
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。Windows 默认是:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def purge_server_modules():
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if name == "main" or name == "db" or name == "minio_tool" or name.startswith("app."):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def write_test_private_key(path: Path):
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(
|
||||||
|
private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordHasher:
|
||||||
|
def hash(self, password: str) -> str:
|
||||||
|
return "test-hash:" + password
|
||||||
|
|
||||||
|
def verify(self, password_hash: str, password: str) -> bool:
|
||||||
|
return password_hash == self.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def server_module(tmp_path, monkeypatch):
|
||||||
|
key_path = tmp_path / "keys" / "manifest_private_key.pem"
|
||||||
|
write_test_private_key(key_path)
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"ENV_FILE_PATH": str(tmp_path / ".env"),
|
||||||
|
"DB_FILE": str(tmp_path / "mini.db"),
|
||||||
|
"SQL_FILE": str(PROJECT_ROOT / "tables.sql"),
|
||||||
|
"ADMIN_TOKEN": "AdminTokenForTests2026",
|
||||||
|
"ADMIN_USERNAME": "admin",
|
||||||
|
"ADMIN_PASSWORD": "AdminPass2026",
|
||||||
|
"ADMIN_JWT_SECRET": "AdminJwtSecretForTests2026",
|
||||||
|
"CLIENT_API_TOKEN": "ClientTokenForTests2026",
|
||||||
|
"CRASH_REPORT_TOKEN": "CrashReportTokenForTests2026",
|
||||||
|
"CRASH_SYMBOL_TOKEN": "CrashSymbolTokenForTests2026",
|
||||||
|
"CRASH_ADMIN_TOKEN": "CrashAdminTokenForTests2026",
|
||||||
|
"LICENSE_KEY_ENCRYPTION_SECRET": "LicenseEncryptionSecretForTests2026",
|
||||||
|
"MANIFEST_PRIVATE_KEY_PATH": str(key_path),
|
||||||
|
"MINIO_AUTO_START": "false",
|
||||||
|
"MINIO_ENDPOINT": "127.0.0.1:1",
|
||||||
|
"MINIO_ACCESS_KEY": "minio_test",
|
||||||
|
"MINIO_SECRET_KEY": "minio_test_secret",
|
||||||
|
"MINIO_BUCKET": "updates",
|
||||||
|
"MINIO_PUBLIC_ENDPOINT": "",
|
||||||
|
"MINIO_CONNECT_TIMEOUT_SEC": "0.1",
|
||||||
|
"MINIO_READ_TIMEOUT_SEC": "0.1",
|
||||||
|
"MINIO_RETRY_TOTAL": "0",
|
||||||
|
"LOCAL_UPLOAD_ROOT": str(tmp_path / "local_uploads"),
|
||||||
|
"MINIO_DATA_DIR": str(tmp_path / "minio_data"),
|
||||||
|
"UPLOAD_SPOOL_DIR": str(tmp_path / "upload_spool"),
|
||||||
|
"CRASH_STORAGE_ROOT": str(tmp_path / "crash_storage"),
|
||||||
|
"TARGET_PLATFORM": "windows",
|
||||||
|
"TARGET_ARCH": "x64",
|
||||||
|
"RELEASE_MAIN_EXECUTABLE": "bin/SimCAE.exe",
|
||||||
|
}
|
||||||
|
for key, value in env.items():
|
||||||
|
monkeypatch.setenv(key, value)
|
||||||
|
monkeypatch.chdir(PROJECT_ROOT)
|
||||||
|
|
||||||
|
purge_server_modules()
|
||||||
|
main = importlib.import_module("main")
|
||||||
|
security = importlib.import_module("app.core.security")
|
||||||
|
security.PASSWORD_HASHER = TestPasswordHasher()
|
||||||
|
yield main
|
||||||
|
purge_server_modules()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import base64
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
def fake_request(**kwargs):
|
||||||
|
return SimpleNamespace(
|
||||||
|
headers=kwargs.get("headers", {}),
|
||||||
|
client=SimpleNamespace(host=kwargs.get("host", "127.0.0.1")),
|
||||||
|
state=SimpleNamespace(**kwargs.get("state", {})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_app(app_id="testapp", app_name="Test App"):
|
||||||
|
from app.api.routes import admin_app_channel
|
||||||
|
from app.schemas.app_channel import AppCreateRequest
|
||||||
|
|
||||||
|
response = admin_app_channel.admin_add_app(AppCreateRequest(app_id=app_id, app_name=app_name))
|
||||||
|
assert response["msg"] == "应用创建成功"
|
||||||
|
|
||||||
|
|
||||||
|
def create_license(app_id="testapp", channel="stable"):
|
||||||
|
from app.api.routes import admin_license
|
||||||
|
from app.schemas.license import LicenseCreateRequest
|
||||||
|
|
||||||
|
response = admin_license.admin_license_create(
|
||||||
|
LicenseCreateRequest(
|
||||||
|
app_id=app_id,
|
||||||
|
channel=channel,
|
||||||
|
customer_name="自动化测试客户",
|
||||||
|
max_devices=1,
|
||||||
|
valid_until="2099-12-31T23:59:59Z",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return response["license_key"]
|
||||||
|
|
||||||
|
|
||||||
|
def encode_device_credential(issue_response: dict) -> str:
|
||||||
|
wrapper = {
|
||||||
|
"identity_text": issue_response["identity_text"],
|
||||||
|
"signature": issue_response["signature"],
|
||||||
|
}
|
||||||
|
return base64.b64encode(json.dumps(wrapper, separators=(",", ":")).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_admin_login_license_and_device_issue(server_module):
|
||||||
|
async with server_module.lifespan(server_module.app):
|
||||||
|
from app.api.routes import admin_auth, admin_license, client_update
|
||||||
|
from app.schemas.admin_auth import AdminLoginRequest
|
||||||
|
from app.schemas.client_update import CheckUpdateRequest, DeviceIssueRequest
|
||||||
|
|
||||||
|
login = admin_auth.login(
|
||||||
|
AdminLoginRequest(username="admin", password="AdminPass2026"),
|
||||||
|
fake_request(headers={"user-agent": "pytest"}),
|
||||||
|
)
|
||||||
|
assert login["success"] is True
|
||||||
|
assert login["data"]["accessToken"]
|
||||||
|
|
||||||
|
add_app()
|
||||||
|
license_key = create_license()
|
||||||
|
|
||||||
|
issue_data = await client_update.issue_device(
|
||||||
|
fake_request(),
|
||||||
|
DeviceIssueRequest(
|
||||||
|
app_id="testapp",
|
||||||
|
channel="stable",
|
||||||
|
license_key=license_key,
|
||||||
|
installation_id="install-test-000001",
|
||||||
|
machine_hash="ab" * 32,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert issue_data["identity"]["app_id"] == "testapp"
|
||||||
|
assert issue_data["identity"]["channel"] == "stable"
|
||||||
|
|
||||||
|
credential = encode_device_credential(issue_data)
|
||||||
|
assert credential
|
||||||
|
check_data = await client_update.check_update(
|
||||||
|
fake_request(state={"device_identity": issue_data["identity"]}),
|
||||||
|
CheckUpdateRequest(
|
||||||
|
app_id="testapp",
|
||||||
|
channel="stable",
|
||||||
|
current_version="1.0.0",
|
||||||
|
client_protocol=3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert check_data["allow_run"] is True
|
||||||
|
assert check_data["release_available"] is False
|
||||||
|
|
||||||
|
licenses = admin_license.admin_license_list(app_id="testapp")
|
||||||
|
assert licenses["list"][0]["used_devices"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_login_license_and_device_issue(server_module):
|
||||||
|
asyncio.run(run_admin_login_license_and_device_issue(server_module))
|
||||||
|
|
||||||
|
|
||||||
|
async def run_publish_payload_creates_version(server_module, tmp_path):
|
||||||
|
async with server_module.lifespan(server_module.app):
|
||||||
|
from app.api.routes import admin_version
|
||||||
|
from app.services import publish_service
|
||||||
|
|
||||||
|
add_app("publishapp", "Publish App")
|
||||||
|
release_dir = tmp_path / "release"
|
||||||
|
bin_dir = release_dir / "bin"
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
exe_path = bin_dir / "SimCAE.exe"
|
||||||
|
readme_path = release_dir / "README.txt"
|
||||||
|
exe_path.write_bytes(b"fake exe")
|
||||||
|
readme_path.write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
result = await publish_service.publish_payload(
|
||||||
|
{
|
||||||
|
"upload_items": [
|
||||||
|
{"path": "bin/SimCAE.exe", "local_path": exe_path},
|
||||||
|
{"path": "README.txt", "local_path": readme_path},
|
||||||
|
],
|
||||||
|
"archive_upload": None,
|
||||||
|
"archive_temp_dir": None,
|
||||||
|
"app_id": "publishapp",
|
||||||
|
"channel": "stable",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"client_protocol": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["file_count"] == 2
|
||||||
|
|
||||||
|
versions = admin_version.admin_get_version_list("publishapp")
|
||||||
|
assert any(item["version"] == "1.0.1" for item in versions["list"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_payload_creates_version(server_module, tmp_path):
|
||||||
|
asyncio.run(run_publish_payload_creates_version(server_module, tmp_path))
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def crash_metadata(client_report_id: str, minidump: bytes):
|
||||||
|
return {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"clientReportId": client_report_id,
|
||||||
|
"crashTimeUtc": "2026-07-16T00:00:00Z",
|
||||||
|
"product": "SIMCAE",
|
||||||
|
"appVersion": "1.0.0",
|
||||||
|
"gitCommit": "test-commit",
|
||||||
|
"buildType": "Release",
|
||||||
|
"channel": "stable",
|
||||||
|
"platform": {"os": "Windows", "arch": "x64"},
|
||||||
|
"crash": {"exceptionCode": "0xC0000005"},
|
||||||
|
"userConsent": {"upload": True},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"kind": "minidump",
|
||||||
|
"name": "crash.dmp",
|
||||||
|
"sha256": hashlib.sha256(minidump).hexdigest(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHeaders(dict):
|
||||||
|
def __init__(self, values: dict[str, str]):
|
||||||
|
super().__init__((key.lower(), value) for key, value in values.items())
|
||||||
|
|
||||||
|
def get(self, key: str, default=None):
|
||||||
|
return super().get(key.lower(), default)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUpload:
|
||||||
|
def __init__(self, filename: str, data: bytes):
|
||||||
|
self.filename = filename
|
||||||
|
self._stream = io.BytesIO(data)
|
||||||
|
|
||||||
|
async def read(self, size: int = -1) -> bytes:
|
||||||
|
return self._stream.read(size)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMultipartRequest:
|
||||||
|
def __init__(self, headers: dict[str, str], form: dict, host: str = "127.0.0.1"):
|
||||||
|
self.headers = FakeHeaders(headers)
|
||||||
|
self._form = form
|
||||||
|
self.client = SimpleNamespace(host=host)
|
||||||
|
|
||||||
|
async def form(self):
|
||||||
|
return self._form
|
||||||
|
|
||||||
|
|
||||||
|
async def run_crash_report_upload_detail_and_duplicate(server_module):
|
||||||
|
async with server_module.lifespan(server_module.app):
|
||||||
|
from app.services import crash_api_service
|
||||||
|
|
||||||
|
minidump = b"fake minidump bytes"
|
||||||
|
client_report_id = str(uuid.uuid4())
|
||||||
|
metadata = crash_metadata(client_report_id, minidump)
|
||||||
|
metadata_bytes = json.dumps(metadata).encode()
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer CrashReportTokenForTests2026",
|
||||||
|
"Idempotency-Key": client_report_id,
|
||||||
|
"Content-Type": "multipart/form-data; boundary=test",
|
||||||
|
"Content-Length": str(len(metadata_bytes) + len(minidump)),
|
||||||
|
"X-SimCAE-Client": "SIMCAE",
|
||||||
|
"X-SimCAE-Version": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
upload = await crash_api_service.create_crash_report(
|
||||||
|
FakeMultipartRequest(
|
||||||
|
headers,
|
||||||
|
{
|
||||||
|
"metadata": FakeUpload("metadata.json", metadata_bytes),
|
||||||
|
"minidump": FakeUpload("crash.dmp", minidump),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert upload.status_code == 201
|
||||||
|
upload_body = json.loads(upload.body)
|
||||||
|
report_id = upload_body["reportId"]
|
||||||
|
|
||||||
|
duplicate = await crash_api_service.create_crash_report(
|
||||||
|
FakeMultipartRequest(
|
||||||
|
headers,
|
||||||
|
{
|
||||||
|
"metadata": FakeUpload("metadata.json", metadata_bytes),
|
||||||
|
"minidump": FakeUpload("crash.dmp", minidump),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert duplicate.status_code == 200
|
||||||
|
assert json.loads(duplicate.body)["duplicate"] is True
|
||||||
|
|
||||||
|
detail = crash_api_service.get_crash_report_detail(
|
||||||
|
report_id,
|
||||||
|
FakeMultipartRequest(
|
||||||
|
{"Authorization": "Bearer CrashAdminTokenForTests2026"},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert detail["clientReportId"] == client_report_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_crash_report_upload_detail_and_duplicate(server_module):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(run_crash_report_upload_detail_and_duplicate(server_module))
|
||||||
Reference in New Issue
Block a user