Initial commit: iOS Build Server
- FastAPI backend with build queue, WebSocket logs, task management - Vue 3 frontend with build/config/history views - Xcode project build automation with IPA export - Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
commit
6f4f625c56
43
.env.example
Normal file
43
.env.example
Normal file
@ -0,0 +1,43 @@
|
||||
# === iOS 自动打包服务配置 ===
|
||||
# 复制此文件为 .env 并修改为实际值
|
||||
|
||||
# ---- 项目路径 ----
|
||||
# iOS 源码项目根目录(也用于 AutoPacking 脚本路径)
|
||||
PROJECT_ROOT=/Users/shen/Work/Code/Readoor
|
||||
|
||||
# 打包输出基础目录(每次打包会在此目录下创建子目录)
|
||||
BUILD_BASE_DIR=/Users/shen/Documents
|
||||
|
||||
# ---- 分支源码管理 ----
|
||||
# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev)
|
||||
GIT_SOURCE_BASE=/Users/shen/Work/Code/ReadoorBranches
|
||||
|
||||
# Git 远程仓库地址,分支目录不存在时自动 clone
|
||||
GIT_REMOTE_URL=git@github.com:your-org/readoor.git
|
||||
|
||||
# ---- 服务端口 ----
|
||||
BACKEND_PORT=8000
|
||||
|
||||
# ---- 管理员账号 ----
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# ---- 并发与清理 ----
|
||||
# 最大并行打包数
|
||||
MAX_CONCURRENT_BUILDS=2
|
||||
|
||||
# 打包目录保留时间(小时)
|
||||
BUILD_DIR_RETENTION_HOURS=24
|
||||
|
||||
# ---- Watchdog 健康监测 ----
|
||||
# 健康检查间隔(秒)
|
||||
WATCHDOG_INTERVAL=30
|
||||
|
||||
# 健康检查 HTTP 超时(秒)
|
||||
WATCHDOG_TIMEOUT=10
|
||||
|
||||
# 连续重启次数上限,超过后进入冷却
|
||||
MAX_RESTART_ATTEMPTS=3
|
||||
|
||||
# 冷却时间(秒),连续重启超限后等待多久再重试
|
||||
COOLDOWN_SECONDS=300
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# 环境变量(含密码)
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
|
||||
# 数据库
|
||||
*.db
|
||||
|
||||
# 日志
|
||||
logs/
|
||||
|
||||
# 服务器 PID
|
||||
.server.pid
|
||||
.watchdog.pid
|
||||
|
||||
# 前端
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/static/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
514
README.md
Normal file
514
README.md
Normal file
@ -0,0 +1,514 @@
|
||||
# iOS 自动打包服务
|
||||
|
||||
基于 FastAPI + Vue 3 的 iOS 应用自动打包服务,支持 Ad_Hoc 和 App_Store 两种打包类型,提供 Web 界面操作、实时日志流、构建历史管理和健康监测。
|
||||
|
||||
## 目录
|
||||
|
||||
- [环境要求](#环境要求)
|
||||
- [快速开始](#快速开始)
|
||||
- [配置说明](#配置说明)
|
||||
- [服务管理](#服务管理)
|
||||
- [Watchdog 健康监测](#watchdog-健康监测)
|
||||
- [macOS 开机自启](#macos-开机自启)
|
||||
- [开发模式](#开发模式)
|
||||
- [自动化测试](#自动化测试)
|
||||
- [项目结构](#项目结构)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 依赖 | 版本要求 | 说明 |
|
||||
|------|----------|------|
|
||||
| macOS | 11+ | 必须,xcodebuild 依赖 |
|
||||
| Xcode | 14+ | 必须,含 Command Line Tools |
|
||||
| Python | 3.9+ | 后端运行 |
|
||||
| Node.js | 18+ | 前端构建 |
|
||||
| CocoaPods | - | 项目已有 Podfile 时需要 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone <repo-url> BuildServer
|
||||
cd BuildServer
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
```
|
||||
|
||||
**必须修改的配置项:**
|
||||
|
||||
```bash
|
||||
# iOS 源码项目路径(包含 readoor.xcworkspace 的目录)
|
||||
PROJECT_ROOT=/path/to/your/Readoor
|
||||
|
||||
# 打包输出目录
|
||||
BUILD_BASE_DIR=/path/to/build/output
|
||||
|
||||
# 管理员密码(建议修改)
|
||||
ADMIN_PASSWORD=your_secure_password
|
||||
```
|
||||
|
||||
### 3. 构建与启动
|
||||
|
||||
```bash
|
||||
# 一键构建(安装依赖 + 构建前端)
|
||||
./deploy.sh build
|
||||
|
||||
# 启动服务
|
||||
./deploy.sh start
|
||||
```
|
||||
|
||||
启动后访问 `http://<服务器IP>:8000` 即可使用。
|
||||
|
||||
### 4.(可选)初始化分支源码目录
|
||||
|
||||
如果需要按分支打包,先准备分支源码目录:
|
||||
|
||||
```bash
|
||||
# 创建分支源码根目录
|
||||
mkdir -p /path/to/ReadoorBranches
|
||||
|
||||
# 手动 clone 主分支(后续自动 pull 更新)
|
||||
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
||||
```
|
||||
|
||||
其他分支无需手动 clone,首次打包时会自动从远程仓库 clone。
|
||||
|
||||
### 5.(可选)启用健康监测
|
||||
|
||||
```bash
|
||||
./deploy.sh watchdog
|
||||
```
|
||||
|
||||
### 6.(可选)设为开机自启
|
||||
|
||||
```bash
|
||||
./deploy/install-service.sh install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
所有配置通过项目根目录的 `.env` 文件管理,修改后重启服务生效。
|
||||
|
||||
### 项目路径
|
||||
|
||||
| 变量 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| `PROJECT_ROOT` | `/Users/shen/Work/Code/Readoor` | iOS 源码项目根目录(AutoPacking 脚本路径) |
|
||||
| `BUILD_BASE_DIR` | `/Users/shen/Documents` | 打包产物输出基础目录 |
|
||||
| `GIT_SOURCE_BASE` | `/path/to/ReadoorBranches` | 分支源码根目录,每个分支一个子目录 |
|
||||
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,分支目录不存在时自动 clone |
|
||||
|
||||
### 服务配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `BACKEND_PORT` | `8000` | 后端服务端口 |
|
||||
| `ADMIN_USERNAME` | `admin` | 管理员用户名 |
|
||||
| `ADMIN_PASSWORD` | `admin123` | 管理员密码 |
|
||||
|
||||
### 打包配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `MAX_CONCURRENT_BUILDS` | `2` | 最大并行打包数 |
|
||||
| `BUILD_DIR_RETENTION_HOURS` | `24` | 打包目录保留时间(小时),超期自动清理 |
|
||||
|
||||
### Watchdog 配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
|
||||
| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) |
|
||||
| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 |
|
||||
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
|
||||
|
||||
---
|
||||
|
||||
## 服务管理
|
||||
|
||||
所有服务管理通过 `deploy.sh` 完成:
|
||||
|
||||
```bash
|
||||
./deploy.sh <command>
|
||||
```
|
||||
|
||||
### 命令一览
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
|
||||
| `start` | 后台启动服务 |
|
||||
| `stop` | 停止服务和 watchdog |
|
||||
| `restart` | 重启服务 |
|
||||
| `status` | 查看服务和 watchdog 运行状态 |
|
||||
| `test` | 一键运行全部测试(后端 + 前端) |
|
||||
| `watchdog` | 启动健康监测(后台守护) |
|
||||
| `watchdog-stop` | 停止健康监测 |
|
||||
| `watchdog-fg` | 前台运行健康监测(调试用) |
|
||||
|
||||
### 常用操作
|
||||
|
||||
```bash
|
||||
# 首次部署
|
||||
./deploy.sh build && ./deploy.sh start
|
||||
|
||||
# 查看状态
|
||||
./deploy.sh status
|
||||
|
||||
# 更新代码后重新部署
|
||||
git pull
|
||||
./deploy.sh build
|
||||
./deploy.sh restart
|
||||
|
||||
# 启用健康监测
|
||||
./deploy.sh watchdog
|
||||
|
||||
# 停止所有服务
|
||||
./deploy.sh stop
|
||||
```
|
||||
|
||||
### 日志
|
||||
|
||||
日志文件位于 `logs/` 目录:
|
||||
|
||||
```
|
||||
logs/
|
||||
├── server.log # 服务运行日志
|
||||
├── watchdog.log # Watchdog 操作日志(重启记录等)
|
||||
├── server-stdout.log # launchd 模式下的标准输出
|
||||
├── server-stderr.log # launchd 模式下的标准错误
|
||||
├── watchdog-stdout.log # launchd watchdog 标准输出
|
||||
└── watchdog-stderr.log # launchd watchdog 标准错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Watchdog 健康监测
|
||||
|
||||
Watchdog 定期检查服务健康状态,发现异常自动重启。
|
||||
|
||||
### 检测逻辑
|
||||
|
||||
```
|
||||
每 N 秒执行一次
|
||||
│
|
||||
├─ 进程是否存在? (kill -0)
|
||||
│ └─ 否 → 重启
|
||||
│
|
||||
└─ HTTP /api/health 返回 200?
|
||||
├─ 是 → 正常,继续轮询
|
||||
└─ 否 → 重启
|
||||
```
|
||||
|
||||
### 重启保护
|
||||
|
||||
- 连续失败次数 <= `MAX_RESTART_ATTEMPTS`:立即重启
|
||||
- 连续失败次数 > `MAX_RESTART_ATTEMPTS`:进入冷却期(`COOLDOWN_SECONDS` 秒),冷却结束后重置计数器重新尝试
|
||||
|
||||
### 运行方式
|
||||
|
||||
```bash
|
||||
# 后台守护(推荐)
|
||||
./deploy.sh watchdog
|
||||
|
||||
# 前台运行(调试,可看到实时输出)
|
||||
./deploy.sh watchdog-fg
|
||||
|
||||
# 查看 watchdog 日志
|
||||
tail -f logs/watchdog.log
|
||||
|
||||
# 停止 watchdog
|
||||
./deploy.sh watchdog-stop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## macOS 开机自启
|
||||
|
||||
通过 macOS 原生的 launchd 实现开机自启和进程守护。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 前置条件:先完成构建
|
||||
./deploy.sh build
|
||||
|
||||
# 安装服务
|
||||
./deploy/install-service.sh install
|
||||
```
|
||||
|
||||
安装后会注册两个 launchd 服务:
|
||||
|
||||
| 服务 | 说明 |
|
||||
|------|------|
|
||||
| `com.readoor.buildserver` | 主服务,进程崩溃自动拉起 |
|
||||
| `com.readoor.buildserver.watchdog` | 健康监测,检测 HTTP 卡死并重启 |
|
||||
|
||||
### 卸载
|
||||
|
||||
```bash
|
||||
./deploy/install-service.sh uninstall
|
||||
```
|
||||
|
||||
### 手动管理 launchd 服务
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
launchctl list | grep readoor
|
||||
|
||||
# 手动停止
|
||||
launchctl unload ~/Library/LaunchAgents/com.readoor.buildserver.plist
|
||||
launchctl unload ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist
|
||||
|
||||
# 手动启动
|
||||
launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发模式
|
||||
|
||||
一键启动本地开发环境(后端 + 前端同时运行):
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
- 前端界面:`http://localhost:3000`(自动代理 API 请求到后端)
|
||||
- 后端 API:`http://localhost:8000`
|
||||
- 前端修改自动热更新,后端修改自动 reload
|
||||
- `Ctrl+C` 同时停止所有服务
|
||||
|
||||
---
|
||||
|
||||
## 自动化测试
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 一键运行全部测试(后端 89 + 前端 35 = 124 个)
|
||||
./deploy.sh test
|
||||
|
||||
# 只运行匹配的测试
|
||||
./deploy.sh test -k test_login
|
||||
|
||||
# 单独运行后端测试
|
||||
.venv/bin/python -m pytest tests/ -v
|
||||
|
||||
# 单独运行前端测试
|
||||
cd frontend && npx vitest run
|
||||
```
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
| 测试文件 | 覆盖内容 | 数量 |
|
||||
|----------|----------|------|
|
||||
| `test_api_health.py` | 健康检查 | 1 |
|
||||
| `test_api_auth.py` | 登录认证 | 3 |
|
||||
| `test_api_config.py` | App/Scheme/Branch/Server/Build 配置 CRUD | 20 |
|
||||
| `test_api_tasks.py` | 任务创建/列表/详情/取消 | 9 |
|
||||
| `test_api_apps.py` | 打包选择接口 | 3 |
|
||||
| `test_websocket.py` | 日志流订阅/发送/完成/清理 | 10 |
|
||||
| `test_downloads.py` | dSYM/混淆映射/二维码下载 | 11 |
|
||||
| `test_build_service.py` | 源码更新/拷贝/配置生成/目录清理 | 11 |
|
||||
| `test_build_queue.py` | 并发控制/任务执行/取消 | 7 |
|
||||
| `test_edge_cases.py` | 边界情况/无效参数/配置备份 | 14 |
|
||||
| `App.test.js` | 登录/退出/导航 | 8 |
|
||||
| `BuildView.vue.test.js` | 打包表单/任务列表/提交 | 8 |
|
||||
| `ConfigView.vue.test.js` | 配置管理/分支增删/设置保存 | 8 |
|
||||
| `HistoryView.vue.test.js` | 历史列表/过滤/下载/二维码 | 11 |
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
BuildServer/
|
||||
├── .env.example # 环境变量模板
|
||||
├── .env # 实际配置(不入库)
|
||||
├── .gitignore
|
||||
├── README.md # 项目文档
|
||||
├── deploy.sh # 部署与管理脚本
|
||||
├── start.sh # 开发模式启动脚本(一键启动前后端)
|
||||
├── requirements.txt # Python 依赖
|
||||
├── requirements-dev.txt # 测试依赖(pytest, httpx)
|
||||
├── pytest.ini # pytest 配置
|
||||
│
|
||||
├── deploy/ # 部署相关
|
||||
│ └── install-service.sh # macOS launchd 服务安装
|
||||
│
|
||||
├── tests/ # 后端测试(pytest)
|
||||
│ ├── conftest.py # 测试 fixtures
|
||||
│ ├── test_api_*.py # API 接口测试
|
||||
│ ├── test_websocket.py # WebSocket 测试
|
||||
│ ├── test_downloads.py # 文件下载测试
|
||||
│ ├── test_build_service.py # 打包服务测试
|
||||
│ ├── test_build_queue.py # 队列测试
|
||||
│ └── test_edge_cases.py # 边界情况测试
|
||||
│
|
||||
├── backend/ # 后端(FastAPI)
|
||||
│ ├── main.py # 入口,路由注册,静态文件服务
|
||||
│ ├── config.py # 配置加载(读取 .env)
|
||||
│ ├── database.py # SQLAlchemy 数据库初始化
|
||||
│ ├── models.py # 数据库模型(Task, BuildConfig)
|
||||
│ ├── schemas.py # Pydantic 请求/响应模型
|
||||
│ ├── routers/
|
||||
│ │ ├── auth.py # 登录认证
|
||||
│ │ ├── config.py # 配置管理(App/Scheme/Server CRUD)
|
||||
│ │ ├── apps.py # 打包选择(只读)
|
||||
│ │ └── tasks.py # 任务管理(创建/列表/取消/下载)
|
||||
│ └── services/
|
||||
│ ├── build_service.py # 打包核心流程
|
||||
│ ├── build_queue.py # 异步任务队列
|
||||
│ └── log_streamer.py # WebSocket 日志流
|
||||
│
|
||||
└── frontend/ # 前端(Vue 3 + Vite)
|
||||
├── vite.config.js # 构建配置,开发代理
|
||||
├── vitest.config.js # 前端测试配置
|
||||
├── index.html
|
||||
└── src/
|
||||
├── App.vue # 根组件(导航/登录)
|
||||
├── router/index.js # 路由
|
||||
├── composables/
|
||||
│ └── useWebSocket.js
|
||||
├── views/
|
||||
│ ├── BuildView.vue # 打包页面
|
||||
│ ├── HistoryView.vue # 历史记录
|
||||
│ └── ConfigView.vue # 管理后台
|
||||
└── __tests__/ # 前端测试(vitest)
|
||||
├── App.test.js
|
||||
├── BuildView.test.js
|
||||
├── ConfigView.test.js
|
||||
└── HistoryView.test.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 打包时提示 "未找到 xcodebuild"
|
||||
|
||||
确保安装了 Xcode 和 Command Line Tools:
|
||||
|
||||
```bash
|
||||
xcode-select --install
|
||||
xcodebuild -version
|
||||
```
|
||||
|
||||
### Q: 前端页面打不开
|
||||
|
||||
检查前端是否已构建:
|
||||
|
||||
```bash
|
||||
ls backend/static/index.html # 应该存在
|
||||
# 如果不存在:
|
||||
./deploy.sh build
|
||||
./deploy.sh restart
|
||||
```
|
||||
|
||||
### Q: 端口被占用
|
||||
|
||||
修改 `.env` 中的 `BACKEND_PORT`,然后重启:
|
||||
|
||||
```bash
|
||||
vim .env
|
||||
./deploy.sh restart
|
||||
```
|
||||
|
||||
### Q: 打包目录占满磁盘
|
||||
|
||||
调整保留时间或手动清理:
|
||||
|
||||
```bash
|
||||
# 缩短保留时间
|
||||
vim .env # BUILD_DIR_RETENTION_HOURS=12
|
||||
./deploy.sh restart
|
||||
|
||||
# 手动清理所有打包目录
|
||||
rm -rf /path/to/build/output/build_readoor_*
|
||||
```
|
||||
|
||||
### Q: Watchdog 频繁重启
|
||||
|
||||
查看 watchdog 日志定位原因:
|
||||
|
||||
```bash
|
||||
tail -50 logs/watchdog.log
|
||||
tail -50 logs/server.log
|
||||
```
|
||||
|
||||
常见原因:
|
||||
- `PROJECT_ROOT` 路径不存在
|
||||
- 磁盘空间不足
|
||||
- Python 依赖缺失(重新执行 `./deploy.sh build`)
|
||||
|
||||
### Q: 如何修改管理员密码
|
||||
|
||||
编辑 `.env`:
|
||||
|
||||
```bash
|
||||
ADMIN_PASSWORD=new_password
|
||||
./deploy.sh restart
|
||||
```
|
||||
|
||||
### Q: 多台 Mac 部署
|
||||
|
||||
每台 Mac 上:
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd BuildServer
|
||||
cp .env.example .env
|
||||
# 编辑 .env 配置路径和密码
|
||||
./deploy.sh build
|
||||
./deploy.sh start
|
||||
./deploy.sh watchdog
|
||||
```
|
||||
|
||||
各机器独立运行,配置互不影响。
|
||||
|
||||
### Q: 如何按分支打包
|
||||
|
||||
1. 在 `.env` 中配置 `GIT_SOURCE_BASE` 和 `GIT_REMOTE_URL`
|
||||
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`)
|
||||
3. 打包时在「代码分支」下拉框中选择分支
|
||||
4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新
|
||||
5. 每个分支有独立的源码目录,支持并行打包不同分支
|
||||
|
||||
### Q: 分支目录占满磁盘怎么办
|
||||
|
||||
```bash
|
||||
# 查看各分支目录大小
|
||||
du -sh /path/to/ReadoorBranches/*
|
||||
|
||||
# 删除不用的分支目录
|
||||
rm -rf /path/to/ReadoorBranches/old-branch
|
||||
```
|
||||
|
||||
### Q: 如何运行测试
|
||||
|
||||
```bash
|
||||
# 一键运行全部测试(后端 + 前端)
|
||||
./deploy.sh test
|
||||
|
||||
# 只运行后端测试
|
||||
.venv/bin/python -m pytest tests/ -v
|
||||
|
||||
# 只运行前端测试
|
||||
cd frontend && npx vitest run
|
||||
|
||||
# 运行指定测试
|
||||
./deploy.sh test -k test_login
|
||||
```
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
67
backend/config.py
Normal file
67
backend/config.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""项目配置 — 从环境变量读取,支持 .env 文件"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 自动加载 .env 文件(如果存在)
|
||||
_env_file = Path(__file__).parent.parent / ".env"
|
||||
if _env_file.exists():
|
||||
with open(_env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
# 项目根目录(iOS 源码,也作为 AutoPacking 脚本来源)
|
||||
PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", "/Users/shen/Work/Code/Readoor"))
|
||||
|
||||
# AutoPacking 目录
|
||||
AUTOPACKING_DIR = PROJECT_ROOT / "AutoPacking"
|
||||
|
||||
# config.json 路径
|
||||
CONFIG_JSON_PATH = AUTOPACKING_DIR / "config.json"
|
||||
|
||||
# 打包基础目录
|
||||
BUILD_BASE_DIR = Path(os.getenv("BUILD_BASE_DIR", "/Users/shen/Documents"))
|
||||
|
||||
# Git 分支源码目录
|
||||
# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录
|
||||
# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone
|
||||
GIT_SOURCE_BASE = Path(os.getenv("GIT_SOURCE_BASE", str(PROJECT_ROOT.parent / "ReadoorBranches")))
|
||||
GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "")
|
||||
|
||||
|
||||
def get_source_dir(branch: str) -> Path:
|
||||
"""获取指定分支的源码目录"""
|
||||
return GIT_SOURCE_BASE / branch
|
||||
|
||||
# 需要拷贝的目录和文件
|
||||
COPY_ITEMS = [
|
||||
"readoor",
|
||||
"readoor.xcodeproj",
|
||||
"readoorTests",
|
||||
"AutoPacking",
|
||||
"podfile",
|
||||
"Pods",
|
||||
"Podfile.lock",
|
||||
"readoor.xcworkspace",
|
||||
]
|
||||
|
||||
# 服务端口
|
||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}"
|
||||
|
||||
# WebSocket 日志队列最大长度
|
||||
LOG_QUEUE_MAX_SIZE = 1000
|
||||
|
||||
# 默认最大并行打包数
|
||||
DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2"))
|
||||
|
||||
# 默认打包目录保留时间(小时)
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24"))
|
||||
|
||||
# 管理员账号配置
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||
39
backend/database.py
Normal file
39
backend/database.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from .config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _migrate_db():
|
||||
"""简单的数据库迁移:为旧表添加缺失的列"""
|
||||
migrations = [
|
||||
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # 列已存在则忽略
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_db()
|
||||
75
backend/main.py
Normal file
75
backend/main.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""FastAPI 主入口"""
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from .database import init_db
|
||||
from .routers import config, apps, tasks, auth
|
||||
from .services.log_streamer import log_streamer
|
||||
from .config import BACKEND_PORT
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期"""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="iOS 自动打包服务",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(apps.router)
|
||||
app.include_router(tasks.router)
|
||||
|
||||
|
||||
@app.websocket("/ws/tasks/{task_id}")
|
||||
async def websocket_logs(websocket: WebSocket, task_id: str):
|
||||
"""WebSocket 实时日志"""
|
||||
await websocket.accept()
|
||||
try:
|
||||
async for msg in log_streamer.subscribe(task_id):
|
||||
await websocket.send_json(msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
"""健康检查"""
|
||||
return {"status": "ok", "port": BACKEND_PORT}
|
||||
|
||||
|
||||
# 静态文件服务(生产模式:前端 build 产物由 FastAPI 直接 serving)
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
"""SPA catch-all:非 API 路由统一返回 index.html"""
|
||||
file_path = STATIC_DIR / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(str(file_path))
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
50
backend/models.py
Normal file
50
backend/models.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""数据库模型"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, Text, TIMESTAMP
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""打包任务表"""
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
app_id = Column(String, nullable=False)
|
||||
app_name = Column(String, nullable=False)
|
||||
build_type = Column(String, nullable=False)
|
||||
scheme_id = Column(String, nullable=False)
|
||||
scheme_name = Column(String, nullable=False)
|
||||
obfuscation = Column(Boolean, default=False)
|
||||
branch = Column(String, default="main")
|
||||
|
||||
# 状态
|
||||
status = Column(String, default="pending") # pending/running/completed/failed/cancelled
|
||||
current_step = Column(String)
|
||||
|
||||
# 时间
|
||||
created_at = Column(TIMESTAMP, default=datetime.utcnow)
|
||||
started_at = Column(TIMESTAMP)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
|
||||
# 产物
|
||||
ipa_path = Column(String)
|
||||
oss_url = Column(String)
|
||||
dsym_path = Column(String)
|
||||
obfuscation_maps_path = Column(String)
|
||||
qr_code_path = Column(String)
|
||||
build_dir = Column(String)
|
||||
|
||||
# 错误
|
||||
error_message = Column(Text)
|
||||
|
||||
# 配置快照
|
||||
config_json = Column(Text)
|
||||
|
||||
|
||||
class BuildConfig(Base):
|
||||
"""打包配置表"""
|
||||
__tablename__ = "build_config"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(String)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
27
backend/routers/apps.py
Normal file
27
backend/routers/apps.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""打包选择 API"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .config import load_config
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["apps"])
|
||||
|
||||
|
||||
@router.get("/apps")
|
||||
async def get_apps_for_build():
|
||||
"""获取 apps 列表(供打包选择)"""
|
||||
config = load_config()
|
||||
return config.get("apps", {})
|
||||
|
||||
|
||||
@router.get("/schemes")
|
||||
async def get_schemes_for_build():
|
||||
"""获取 schemes 列表(供打包选择)"""
|
||||
config = load_config()
|
||||
return config.get("schemes", {})
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def get_branches_for_build():
|
||||
"""获取分支列表(供打包选择)"""
|
||||
config = load_config()
|
||||
return config.get("branches", ["main"])
|
||||
19
backend/routers/auth.py
Normal file
19
backend/routers/auth.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""认证 API"""
|
||||
from fastapi import APIRouter
|
||||
from ..schemas import LoginRequest, LoginResponse
|
||||
from ..config import ADMIN_USERNAME, ADMIN_PASSWORD
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: LoginRequest):
|
||||
"""管理员登录"""
|
||||
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
|
||||
return LoginResponse(
|
||||
token="admin-token",
|
||||
username=request.username,
|
||||
is_admin=True
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
398
backend/routers/config.py
Normal file
398
backend/routers/config.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""配置管理 API"""
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import BuildConfig
|
||||
from ..schemas import BuildConfigUpdate
|
||||
from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
_config_lock = asyncio.Lock()
|
||||
|
||||
# 默认上传配置
|
||||
DEFAULT_UPLOAD = {
|
||||
"mode": "oss",
|
||||
"oss": {
|
||||
"access_key_id": "",
|
||||
"access_key_secret": "",
|
||||
"endpoint": "oss-cn-beijing.aliyuncs.com",
|
||||
"bucket_name": "",
|
||||
"base_url": "",
|
||||
},
|
||||
"webdav": {
|
||||
"server_url": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"base_path": "/ios-builds",
|
||||
"public_url": "",
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": False,
|
||||
"webhook_url": "",
|
||||
"secret": "",
|
||||
},
|
||||
}
|
||||
|
||||
# 默认服务器环境配置
|
||||
DEFAULT_SERVERS = {
|
||||
"测试环境": {
|
||||
"api": "https://api3-dev.readoor.cn",
|
||||
"assDom": "applinks:dev-data1.readoor.cn",
|
||||
"universalLink": "https://dev-data1.readoor.cn"
|
||||
},
|
||||
"正式环境": {
|
||||
"api": "https://api3.readoor.cn",
|
||||
"assDom": "applinks:data1.readoor.cn",
|
||||
"universalLink": "https://data1.readoor.cn"
|
||||
},
|
||||
"华师大环境": {
|
||||
"api": "https://api3.ecnupress.com.cn",
|
||||
"assDom": "applinks:data1.ecnupress.com.cn",
|
||||
"universalLink": "https://data1.ecnupress.com.cn"
|
||||
},
|
||||
"外教环境": {
|
||||
"api": "https://weread-api3.sflep.com/api3",
|
||||
"assDom": "applinks:wereadossda.sflep.com",
|
||||
"universalLink": "https://wereadossda.sflep.com"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""读取 config.json"""
|
||||
if not CONFIG_JSON_PATH.exists():
|
||||
return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD}
|
||||
with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
# 确保必要字段存在
|
||||
if "servers" not in config:
|
||||
config["servers"] = DEFAULT_SERVERS
|
||||
if "branches" not in config:
|
||||
config["branches"] = ["main"]
|
||||
if "upload" not in config:
|
||||
config["upload"] = DEFAULT_UPLOAD
|
||||
return config
|
||||
|
||||
|
||||
def save_config(config: dict):
|
||||
"""保存 config.json(自动备份)"""
|
||||
if CONFIG_JSON_PATH.exists():
|
||||
backup_path = CONFIG_JSON_PATH.with_suffix(".json.bak")
|
||||
shutil.copy2(CONFIG_JSON_PATH, backup_path)
|
||||
with open(CONFIG_JSON_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def get_build_config_value(db: Session, key: str, default=None) -> str:
|
||||
"""获取打包配置值"""
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == key).first()
|
||||
return config.value if config else default
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_full_config():
|
||||
"""获取完整 config.json"""
|
||||
return load_config()
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_full_config(config: dict):
|
||||
"""更新整个 config.json"""
|
||||
async with _config_lock:
|
||||
save_config(config)
|
||||
return {"message": "配置已更新"}
|
||||
|
||||
|
||||
@router.get("/apps")
|
||||
async def get_apps():
|
||||
"""获取 apps 配置"""
|
||||
config = load_config()
|
||||
return config.get("apps", {})
|
||||
|
||||
|
||||
@router.post("/apps")
|
||||
async def create_app(app: dict):
|
||||
"""新增 app"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
# 自动生成 ID
|
||||
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
|
||||
apps[new_id] = app
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
return {"id": new_id, "message": "App 已创建"}
|
||||
|
||||
|
||||
@router.put("/apps/{app_id}")
|
||||
async def update_app(app_id: str, app: dict):
|
||||
"""更新指定 app"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
if app_id not in apps:
|
||||
raise HTTPException(status_code=404, detail="App 不存在")
|
||||
|
||||
apps[app_id] = app
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
return {"message": "App 已更新"}
|
||||
|
||||
|
||||
@router.delete("/apps/{app_id}")
|
||||
async def delete_app(app_id: str):
|
||||
"""删除指定 app"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
|
||||
if app_id not in apps:
|
||||
raise HTTPException(status_code=404, detail="App 不存在")
|
||||
|
||||
del apps[app_id]
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
return {"message": "App 已删除"}
|
||||
|
||||
|
||||
@router.get("/schemes")
|
||||
async def get_schemes():
|
||||
"""获取 schemes 配置"""
|
||||
config = load_config()
|
||||
return config.get("schemes", {})
|
||||
|
||||
|
||||
@router.post("/schemes")
|
||||
async def create_scheme(scheme: dict):
|
||||
"""新增 scheme"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
numeric_keys = [int(k) for k in schemes.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
|
||||
schemes[new_id] = scheme
|
||||
config["schemes"] = schemes
|
||||
save_config(config)
|
||||
return {"id": new_id, "message": "Scheme 已创建"}
|
||||
|
||||
|
||||
@router.put("/schemes/{scheme_id}")
|
||||
async def update_scheme(scheme_id: str, scheme: dict):
|
||||
"""更新指定 scheme"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
if scheme_id not in schemes:
|
||||
raise HTTPException(status_code=404, detail="Scheme 不存在")
|
||||
|
||||
schemes[scheme_id] = scheme
|
||||
config["schemes"] = schemes
|
||||
save_config(config)
|
||||
return {"message": "Scheme 已更新"}
|
||||
|
||||
|
||||
@router.delete("/schemes/{scheme_id}")
|
||||
async def delete_scheme(scheme_id: str):
|
||||
"""删除指定 scheme"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
if scheme_id not in schemes:
|
||||
raise HTTPException(status_code=404, detail="Scheme 不存在")
|
||||
|
||||
del schemes[scheme_id]
|
||||
config["schemes"] = schemes
|
||||
save_config(config)
|
||||
return {"message": "Scheme 已删除"}
|
||||
|
||||
|
||||
# 分支管理 API
|
||||
@router.get("/branches")
|
||||
async def get_branches():
|
||||
"""获取分支列表"""
|
||||
config = load_config()
|
||||
return config.get("branches", ["main"])
|
||||
|
||||
|
||||
@router.post("/branches")
|
||||
async def add_branch(data: dict):
|
||||
"""新增分支"""
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="分支名称不能为空")
|
||||
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
branches = config.get("branches", ["main"])
|
||||
if name in branches:
|
||||
raise HTTPException(status_code=400, detail="分支已存在")
|
||||
branches.append(name)
|
||||
config["branches"] = branches
|
||||
save_config(config)
|
||||
return {"message": "分支已添加"}
|
||||
|
||||
|
||||
@router.delete("/branches/{branch_name}")
|
||||
async def delete_branch(branch_name: str):
|
||||
"""删除分支"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
branches = config.get("branches", ["main"])
|
||||
if branch_name not in branches:
|
||||
raise HTTPException(status_code=404, detail="分支不存在")
|
||||
branches.remove(branch_name)
|
||||
config["branches"] = branches
|
||||
save_config(config)
|
||||
return {"message": "分支已删除"}
|
||||
|
||||
|
||||
@router.get("/build")
|
||||
async def get_build_settings(db: Session = Depends(get_db)):
|
||||
"""获取打包设置"""
|
||||
return {
|
||||
"max_concurrent_builds": int(get_build_config_value(db, "max_concurrent_builds", DEFAULT_MAX_CONCURRENT_BUILDS)),
|
||||
"build_dir_retention_hours": int(get_build_config_value(db, "build_dir_retention_hours", DEFAULT_BUILD_DIR_RETENTION_HOURS)),
|
||||
"build_base_dir": get_build_config_value(db, "build_base_dir", "/Users/shen/Documents"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/build")
|
||||
async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depends(get_db)):
|
||||
"""更新打包设置"""
|
||||
if settings.max_concurrent_builds is not None:
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "max_concurrent_builds").first()
|
||||
if config:
|
||||
config.value = str(settings.max_concurrent_builds)
|
||||
else:
|
||||
db.add(BuildConfig(key="max_concurrent_builds", value=str(settings.max_concurrent_builds)))
|
||||
|
||||
if settings.build_dir_retention_hours is not None:
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
if config:
|
||||
config.value = str(settings.build_dir_retention_hours)
|
||||
else:
|
||||
db.add(BuildConfig(key="build_dir_retention_hours", value=str(settings.build_dir_retention_hours)))
|
||||
|
||||
if settings.build_base_dir is not None:
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_base_dir").first()
|
||||
if config:
|
||||
config.value = settings.build_base_dir
|
||||
else:
|
||||
db.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir))
|
||||
|
||||
db.commit()
|
||||
return {"message": "打包设置已更新"}
|
||||
|
||||
|
||||
# 上传配置 API
|
||||
@router.get("/upload")
|
||||
async def get_upload_config():
|
||||
"""获取上传配置"""
|
||||
config = load_config()
|
||||
return config.get("upload", DEFAULT_UPLOAD)
|
||||
|
||||
|
||||
@router.put("/upload")
|
||||
async def update_upload_config(upload_data: dict):
|
||||
"""更新上传配置"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
config["upload"] = upload_data
|
||||
save_config(config)
|
||||
return {"message": "上传配置已更新"}
|
||||
|
||||
|
||||
# 服务器环境管理 API
|
||||
@router.get("/servers")
|
||||
async def get_servers():
|
||||
"""获取服务器环境配置"""
|
||||
config = load_config()
|
||||
return config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
|
||||
@router.post("/servers")
|
||||
async def create_server(server_data: dict):
|
||||
"""新增服务器环境"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
servers = config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
name = server_data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="环境名称不能为空")
|
||||
if name in servers:
|
||||
raise HTTPException(status_code=400, detail="环境名称已存在")
|
||||
|
||||
servers[name] = {
|
||||
"api": server_data.get("api", ""),
|
||||
"assDom": server_data.get("assDom", ""),
|
||||
"universalLink": server_data.get("universalLink", ""),
|
||||
}
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已创建"}
|
||||
|
||||
|
||||
@router.put("/servers/{server_name}")
|
||||
async def update_server(server_name: str, server_data: dict):
|
||||
"""更新服务器环境"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
servers = config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
if server_name not in servers:
|
||||
raise HTTPException(status_code=404, detail="环境不存在")
|
||||
|
||||
# 如果名称变了,需要删除旧的
|
||||
new_name = server_data.get("name", server_name).strip()
|
||||
if new_name != server_name:
|
||||
if new_name in servers:
|
||||
raise HTTPException(status_code=400, detail="新环境名称已存在")
|
||||
servers[new_name] = servers.pop(server_name)
|
||||
else:
|
||||
new_name = server_name
|
||||
|
||||
servers[new_name] = {
|
||||
"api": server_data.get("api", ""),
|
||||
"assDom": server_data.get("assDom", ""),
|
||||
"universalLink": server_data.get("universalLink", ""),
|
||||
}
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已更新"}
|
||||
|
||||
|
||||
@router.delete("/servers/{server_name}")
|
||||
async def delete_server(server_name: str):
|
||||
"""删除服务器环境"""
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
servers = config.get("servers", DEFAULT_SERVERS)
|
||||
|
||||
if server_name not in servers:
|
||||
raise HTTPException(status_code=404, detail="环境不存在")
|
||||
|
||||
# 检查是否有 App 在使用此环境
|
||||
apps = config.get("apps", {})
|
||||
for app in apps.values():
|
||||
if app.get("server") == server_name:
|
||||
raise HTTPException(status_code=400, detail=f"无法删除:有 App 正在使用此环境")
|
||||
|
||||
del servers[server_name]
|
||||
config["servers"] = servers
|
||||
save_config(config)
|
||||
return {"message": "服务器环境已删除"}
|
||||
158
backend/routers/tasks.py
Normal file
158
backend/routers/tasks.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""任务 API"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.background import BackgroundTask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Task
|
||||
from ..schemas import TaskCreate, TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResponse)
|
||||
async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
||||
"""创建打包任务"""
|
||||
from .config import load_config
|
||||
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
if task.app_id not in apps:
|
||||
raise HTTPException(status_code=400, detail="App 不存在")
|
||||
if task.scheme_id not in schemes:
|
||||
raise HTTPException(status_code=400, detail="Scheme 不存在")
|
||||
|
||||
app = apps[task.app_id]
|
||||
scheme = schemes[task.scheme_id]
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
db_task = Task(
|
||||
id=task_id,
|
||||
app_id=task.app_id,
|
||||
app_name=app.get("name", ""),
|
||||
build_type=task.build_type,
|
||||
scheme_id=task.scheme_id,
|
||||
scheme_name=scheme.get("name", ""),
|
||||
obfuscation=task.obfuscation,
|
||||
branch=task.branch,
|
||||
status="pending",
|
||||
)
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
# 启动打包任务
|
||||
from ..services.build_queue import build_queue
|
||||
await build_queue.submit(task_id)
|
||||
|
||||
return db_task
|
||||
|
||||
|
||||
@router.get("", response_model=List[TaskResponse])
|
||||
async def list_tasks(
|
||||
status: str = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取任务列表"""
|
||||
query = db.query(Task)
|
||||
if status:
|
||||
query = query.filter(Task.status == status)
|
||||
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return tasks
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(task_id: str, db: Session = Depends(get_db)):
|
||||
"""获取任务详情"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def cancel_task(task_id: str, db: Session = Depends(get_db)):
|
||||
"""取消任务"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status in ("completed", "failed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="任务已完成或已取消")
|
||||
|
||||
task.status = "cancelled"
|
||||
db.commit()
|
||||
|
||||
# 尝试取消运行中的任务
|
||||
from ..services.build_queue import build_queue
|
||||
build_queue.cancel(task_id)
|
||||
|
||||
return {"message": "任务已取消"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/dsym")
|
||||
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
||||
"""下载 dSYM 文件"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if not task.dsym_path:
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
|
||||
|
||||
if not os.path.exists(task.dsym_path):
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
|
||||
|
||||
return FileResponse(task.dsym_path, filename=os.path.basename(task.dsym_path))
|
||||
|
||||
|
||||
@router.get("/{task_id}/obfuscation-maps")
|
||||
async def download_obfuscation_maps(task_id: str, db: Session = Depends(get_db)):
|
||||
"""下载混淆映射表"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if not task.obfuscation_maps_path:
|
||||
raise HTTPException(status_code=404, detail="混淆映射表不存在")
|
||||
|
||||
maps_path = task.obfuscation_maps_path
|
||||
if not os.path.exists(maps_path):
|
||||
raise HTTPException(status_code=404, detail="混淆映射表已被清理")
|
||||
|
||||
# 如果是目录,打包成 zip
|
||||
if os.path.isdir(maps_path):
|
||||
fd, zip_path = tempfile.mkstemp(suffix=".zip")
|
||||
os.close(fd)
|
||||
shutil.make_archive(zip_path.replace(".zip", ""), "zip", maps_path)
|
||||
return FileResponse(
|
||||
zip_path,
|
||||
filename=f"{task.app_name}_混淆映射.zip",
|
||||
background=BackgroundTask(os.remove, zip_path),
|
||||
)
|
||||
|
||||
return FileResponse(maps_path, filename=os.path.basename(maps_path))
|
||||
|
||||
|
||||
@router.get("/{task_id}/qrcode")
|
||||
async def download_qrcode(task_id: str, db: Session = Depends(get_db)):
|
||||
"""下载二维码图片"""
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if not task.qr_code_path:
|
||||
raise HTTPException(status_code=404, detail="二维码不存在")
|
||||
|
||||
if not os.path.exists(task.qr_code_path):
|
||||
raise HTTPException(status_code=404, detail="二维码文件已被清理")
|
||||
|
||||
return FileResponse(task.qr_code_path, media_type="image/png")
|
||||
59
backend/schemas.py
Normal file
59
backend/schemas.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Pydantic 模型"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# 打包任务
|
||||
class TaskCreate(BaseModel):
|
||||
app_id: str
|
||||
build_type: str
|
||||
scheme_id: str
|
||||
obfuscation: bool = False
|
||||
branch: str = "main"
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
id: str
|
||||
app_id: str
|
||||
app_name: str
|
||||
build_type: str
|
||||
scheme_id: str
|
||||
scheme_name: str
|
||||
obfuscation: bool
|
||||
branch: str
|
||||
status: str
|
||||
current_step: Optional[str]
|
||||
created_at: Optional[datetime]
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
ipa_path: Optional[str]
|
||||
oss_url: Optional[str]
|
||||
dsym_path: Optional[str]
|
||||
obfuscation_maps_path: Optional[str]
|
||||
qr_code_path: Optional[str]
|
||||
build_dir: Optional[str]
|
||||
error_message: Optional[str]
|
||||
config_json: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 登录
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
# 打包配置
|
||||
class BuildConfigUpdate(BaseModel):
|
||||
max_concurrent_builds: Optional[int] = None
|
||||
build_dir_retention_hours: Optional[int] = None
|
||||
build_base_dir: Optional[str] = None
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
80
backend/services/build_queue.py
Normal file
80
backend/services/build_queue.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""并行打包队列管理"""
|
||||
import asyncio
|
||||
from typing import Dict, Callable, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from .log_streamer import log_streamer
|
||||
|
||||
|
||||
class BuildQueue:
|
||||
"""打包任务队列"""
|
||||
|
||||
def __init__(self, max_concurrent: int = 2):
|
||||
self._max_concurrent = max_concurrent
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self._queue: asyncio.Queue = asyncio.Queue()
|
||||
self._running_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._workers: list = []
|
||||
self._started = False
|
||||
|
||||
async def start(self):
|
||||
"""启动工作线程"""
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
for i in range(self._max_concurrent):
|
||||
worker = asyncio.create_task(self._worker(f"worker-{i}"))
|
||||
self._workers.append(worker)
|
||||
|
||||
async def _worker(self, name: str):
|
||||
"""工作协程"""
|
||||
while True:
|
||||
task_id, build_func = await self._queue.get()
|
||||
try:
|
||||
await self._semaphore.acquire()
|
||||
try:
|
||||
self._running_tasks[task_id] = asyncio.current_task()
|
||||
await build_func(task_id)
|
||||
finally:
|
||||
self._running_tasks.pop(task_id, None)
|
||||
self._semaphore.release()
|
||||
except Exception as e:
|
||||
await log_streamer.emit_error(task_id, f"任务异常: {str(e)}")
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
async def submit(self, task_id: str, build_func: Optional[Callable] = None):
|
||||
"""提交打包任务"""
|
||||
if not self._started:
|
||||
await self.start()
|
||||
|
||||
if build_func is None:
|
||||
from .build_service import run_build_task
|
||||
build_func = run_build_task
|
||||
|
||||
await self._queue.put((task_id, build_func))
|
||||
await log_streamer.emit(task_id, "任务已加入队列,等待执行...")
|
||||
|
||||
def cancel(self, task_id: str):
|
||||
"""取消任务"""
|
||||
task = self._running_tasks.get(task_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
|
||||
def update_max_concurrent(self, new_max: int):
|
||||
"""更新最大并发数"""
|
||||
self._max_concurrent = new_max
|
||||
# 注意:运行时修改需要重建 semaphore,这里简化处理
|
||||
# 实际使用时建议重启服务生效
|
||||
|
||||
@property
|
||||
def queue_size(self) -> int:
|
||||
return self._queue.qsize()
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
return len(self._running_tasks)
|
||||
|
||||
|
||||
# 全局单例
|
||||
build_queue = BuildQueue()
|
||||
590
backend/services/build_service.py
Normal file
590
backend/services/build_service.py
Normal file
@ -0,0 +1,590 @@
|
||||
"""打包服务核心"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import (
|
||||
PROJECT_ROOT,
|
||||
AUTOPACKING_DIR,
|
||||
BUILD_BASE_DIR,
|
||||
COPY_ITEMS,
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||||
GIT_REMOTE_URL,
|
||||
get_source_dir,
|
||||
)
|
||||
from .log_streamer import log_streamer
|
||||
|
||||
|
||||
def _cleanup_build_dir(build_dir: Path):
|
||||
"""删除整个打包目录"""
|
||||
try:
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_old_builds(db):
|
||||
"""清理超过保留时间的打包目录"""
|
||||
from ..models import BuildConfig
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
hours = int(config.value) if config else DEFAULT_BUILD_DIR_RETENTION_HOURS
|
||||
cutoff = datetime.utcnow().timestamp() - hours * 3600
|
||||
|
||||
base = BUILD_BASE_DIR
|
||||
if not base.exists():
|
||||
return
|
||||
for d in base.iterdir():
|
||||
if d.is_dir() and d.name.startswith("build_readoor_"):
|
||||
try:
|
||||
if d.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _db_update(db, task, **fields):
|
||||
"""同步更新 task 字段并 commit(在线程中调用)"""
|
||||
for k, v in fields.items():
|
||||
setattr(task, k, v)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def update_source(task_id: str, source_dir: Path, branch: str):
|
||||
"""确保分支源码目录存在且为最新"""
|
||||
if not source_dir.exists():
|
||||
# 首次:从远程 clone
|
||||
if not GIT_REMOTE_URL:
|
||||
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
|
||||
source_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
await log_streamer.emit(task_id, f"克隆仓库: {GIT_REMOTE_URL} → {branch}")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "clone", "-b", branch, "--single-branch", GIT_REMOTE_URL, str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"git clone 失败: {branch}")
|
||||
else:
|
||||
# 已存在:fetch + checkout + pull
|
||||
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
|
||||
|
||||
# fetch
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "fetch", "origin",
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception("git fetch 失败")
|
||||
|
||||
# checkout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "checkout", branch,
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"git checkout {branch} 失败")
|
||||
|
||||
# pull
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "pull", "origin", branch,
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"git pull {branch} 失败")
|
||||
|
||||
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
|
||||
|
||||
|
||||
async def run_build_task(task_id: str):
|
||||
"""执行打包任务"""
|
||||
from ..database import SessionLocal
|
||||
from ..models import Task
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = await asyncio.to_thread(
|
||||
lambda: db.query(Task).filter(Task.id == task_id).first()
|
||||
)
|
||||
if not task:
|
||||
return
|
||||
|
||||
# 创建日志队列(必须在 emit 之前)
|
||||
log_streamer.create_queue(task_id)
|
||||
|
||||
# 更新状态
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="running", started_at=datetime.utcnow())
|
||||
|
||||
await log_streamer.emit_step(task_id, "开始打包")
|
||||
|
||||
# 清理过期的打包目录
|
||||
await asyncio.to_thread(_cleanup_old_builds, db)
|
||||
|
||||
build_dir = None
|
||||
try:
|
||||
# 1. 更新分支源码并拷贝
|
||||
source_dir = get_source_dir(task.branch)
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
||||
if GIT_REMOTE_URL:
|
||||
# 配置了远程仓库,从分支目录获取源码
|
||||
await update_source(task_id, source_dir, task.branch)
|
||||
elif source_dir.exists():
|
||||
# 分支目录已存在,直接使用
|
||||
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
||||
else:
|
||||
# 未配置远程仓库且分支目录不存在,使用默认源码目录
|
||||
source_dir = PROJECT_ROOT
|
||||
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL,使用默认源码: {source_dir}")
|
||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||
|
||||
# 2. 生成配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
||||
config_data = await generate_config(task_id, task, build_dir)
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
config_json=json.dumps(config_data, ensure_ascii=False))
|
||||
|
||||
# 3. 替换项目配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="patch")
|
||||
await patch_project(task_id, task, config_data, build_dir)
|
||||
|
||||
# 4. 代码混淆(可选)
|
||||
if task.obfuscation:
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
|
||||
await run_obfuscation(task_id, task, build_dir, source_dir)
|
||||
|
||||
# 5. 构建项目
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="build")
|
||||
ipa_path = await build_project(task_id, task, build_dir)
|
||||
|
||||
# 查找 dSYM
|
||||
dsym_path = await find_dsym(task_id, build_dir)
|
||||
|
||||
# 查找混淆映射表
|
||||
obf_maps_path = build_dir / "obfuscation_maps"
|
||||
|
||||
update_fields = {"ipa_path": str(ipa_path)}
|
||||
if dsym_path:
|
||||
update_fields["dsym_path"] = str(dsym_path)
|
||||
if obf_maps_path.exists():
|
||||
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
|
||||
await asyncio.to_thread(_db_update, db, task, **update_fields)
|
||||
|
||||
# 6. 上传分发(仅 Ad_Hoc)
|
||||
if task.build_type == "Ad_Hoc":
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="upload")
|
||||
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path)
|
||||
upload_fields = {}
|
||||
if oss_url:
|
||||
upload_fields["oss_url"] = oss_url
|
||||
if qr_code_path:
|
||||
upload_fields["qr_code_path"] = qr_code_path
|
||||
if upload_fields:
|
||||
await asyncio.to_thread(_db_update, db, task, **upload_fields)
|
||||
|
||||
# 完成
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="completed", completed_at=datetime.utcnow(),
|
||||
current_step=None)
|
||||
|
||||
await log_streamer.emit_step(task_id, "打包完成")
|
||||
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
|
||||
if task.oss_url:
|
||||
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
|
||||
|
||||
# 清理打包目录(保留产物文件,删除源码拷贝)
|
||||
await log_streamer.emit(task_id, "清理临时文件...")
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="cancelled", completed_at=datetime.utcnow())
|
||||
await log_streamer.emit(task_id, "任务已取消")
|
||||
if build_dir and build_dir.exists():
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
except Exception as e:
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="failed", completed_at=datetime.utcnow(),
|
||||
error_message=str(e))
|
||||
await log_streamer.emit_error(task_id, f"打包失败: {str(e)}")
|
||||
if build_dir and build_dir.exists():
|
||||
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
||||
|
||||
finally:
|
||||
log_streamer.complete(task_id)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def copy_source_code(task_id: str, task, source_dir: Path) -> Path:
|
||||
"""从分支源码目录拷贝代码到打包目录"""
|
||||
await log_streamer.emit_step(task_id, "拷贝代码")
|
||||
|
||||
build_dir = BUILD_BASE_DIR / f"build_readoor_{task_id}"
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
build_dir.mkdir(parents=True)
|
||||
|
||||
for item in COPY_ITEMS:
|
||||
src = source_dir / item
|
||||
dst = build_dir / item
|
||||
if src.is_dir():
|
||||
await log_streamer.emit(task_id, f"拷贝目录: {item}")
|
||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".git", "DerivedData"))
|
||||
elif src.is_file():
|
||||
await log_streamer.emit(task_id, f"拷贝文件: {item}")
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
await log_streamer.emit(task_id, "代码拷贝完成")
|
||||
return build_dir
|
||||
|
||||
|
||||
async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
"""生成打包配置"""
|
||||
await log_streamer.emit_step(task_id, "生成配置")
|
||||
|
||||
from ..routers.config import load_config
|
||||
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
schemes = config.get("schemes", {})
|
||||
|
||||
app = apps.get(task.app_id, {})
|
||||
scheme = schemes.get(task.scheme_id, {})
|
||||
|
||||
# 读取版本号
|
||||
start_build_path = AUTOPACKING_DIR / "start_build_app.py"
|
||||
app_ver = "2.180.0"
|
||||
build_ver = "2.180.0.0"
|
||||
|
||||
if start_build_path.exists():
|
||||
content = start_build_path.read_text()
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("App_Ver"):
|
||||
app_ver = line.split('"')[1] if '"' in line else app_ver
|
||||
elif line.startswith("Build_Ver"):
|
||||
build_ver = line.split('"')[1] if '"' in line else build_ver
|
||||
|
||||
# 构建配置
|
||||
config_data = {
|
||||
"VERSION": app_ver,
|
||||
"BUILD_VERSION": build_ver,
|
||||
"SERVER": app.get("server", ""),
|
||||
"API": app.get("API", ""),
|
||||
"APPID_NAME": app.get("name", ""),
|
||||
"APPID": app.get("AppGuid", app.get("AppId", "")),
|
||||
"SCHEME": scheme.get("name", ""),
|
||||
"OSS_FLODER": scheme.get("ossFloder", ""),
|
||||
"BUILD_TYPE": task.build_type,
|
||||
"ENABLE_OBFUSCATION": task.obfuscation,
|
||||
}
|
||||
|
||||
# 证书配置
|
||||
certificates = app.get("certificates", {})
|
||||
cert = certificates.get(task.build_type, {})
|
||||
|
||||
if cert:
|
||||
config_data["CERTIFICATE"] = cert.get("cer", "")
|
||||
config_data["PROVISIONING_PROFILE"] = cert.get("pro", "")
|
||||
config_data["BUNDLE_ID"] = cert.get("name", "")
|
||||
config_data["THEME"] = cert.get("theme", "")
|
||||
|
||||
# 其他配置
|
||||
for key in ["weixinlogin", "weixinpay", "tencent", "AssDom", "UniversalLink", "AlivcLicenseKey"]:
|
||||
if key in app:
|
||||
config_data[key] = app[key]
|
||||
|
||||
# 写入配置文件
|
||||
config_output_path = build_dir / "config_output.json"
|
||||
with open(config_output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
await log_streamer.emit(task_id, "配置已生成")
|
||||
return config_data
|
||||
|
||||
|
||||
async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
|
||||
"""替换项目配置"""
|
||||
await log_streamer.emit_step(task_id, "替换项目配置")
|
||||
|
||||
# 根据 scheme 选择不同的替换逻辑
|
||||
if config_data.get("SCHEME") == "readoorDict":
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
"--dict-config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
"--config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
|
||||
# 使用 subprocess 执行替换
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("项目配置替换失败")
|
||||
|
||||
await log_streamer.emit(task_id, "项目配置替换完成")
|
||||
|
||||
|
||||
async def run_obfuscation(task_id: str, task, build_dir: Path, source_root: Path):
|
||||
"""执行代码混淆"""
|
||||
await log_streamer.emit_step(task_id, "代码混淆")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TARGET_NAME"] = task.scheme_name
|
||||
env["SKIP_OBF_PHASE"] = "1"
|
||||
|
||||
# 自检
|
||||
self_check_script = AUTOPACKING_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bash", str(self_check_script), str(source_root),
|
||||
cwd=str(build_dir),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("混淆自检失败")
|
||||
|
||||
await log_streamer.emit(task_id, "混淆自检通过")
|
||||
|
||||
# 执行混淆
|
||||
obfuscate_script = AUTOPACKING_DIR / "obfuscation" / "obfuscate_symbols.py"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"python3", str(obfuscate_script), "--archive-mode",
|
||||
cwd=str(build_dir),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("代码混淆失败")
|
||||
|
||||
await log_streamer.emit(task_id, "代码混淆完成")
|
||||
|
||||
|
||||
async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
||||
"""构建项目"""
|
||||
await log_streamer.emit_step(task_id, "构建项目")
|
||||
|
||||
scheme = task.scheme_name
|
||||
export_path = build_dir / "build"
|
||||
archive_path = export_path / f"{scheme}.xcarchive"
|
||||
workspace_path = build_dir / "readoor.xcworkspace"
|
||||
|
||||
export_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 清理
|
||||
await log_streamer.emit(task_id, "清理项目...")
|
||||
clean_cmd = (
|
||||
f"xcodebuild clean -workspace {workspace_path.name} "
|
||||
f"-scheme {scheme} "
|
||||
f"-configuration Release "
|
||||
f"-derivedDataPath {export_path / 'derived_data'}"
|
||||
)
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
clean_cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
# Archive
|
||||
await log_streamer.emit(task_id, "开始 Archive...")
|
||||
archive_cmd = (
|
||||
f"xcodebuild archive -workspace {workspace_path.name} "
|
||||
f"-scheme {scheme} "
|
||||
f"-configuration Release "
|
||||
f"-archivePath {archive_path} "
|
||||
f"-derivedDataPath {export_path / 'derived_data'} "
|
||||
f"-destination generic/platform=ios -quiet"
|
||||
)
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
archive_cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("Archive 失败")
|
||||
|
||||
# 导出 IPA
|
||||
await log_streamer.emit(task_id, "导出 IPA...")
|
||||
export_plist = build_dir / "exportOptions.plist"
|
||||
export_cmd = (
|
||||
f"xcodebuild -exportArchive "
|
||||
f"-archivePath {archive_path} "
|
||||
f"-exportPath {export_path} "
|
||||
f"-exportOptionsPlist {export_plist}"
|
||||
)
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
export_cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
await process.wait()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("导出 IPA 失败")
|
||||
|
||||
# 查找 IPA 文件
|
||||
ipa_files = list(export_path.glob("*.ipa"))
|
||||
if not ipa_files:
|
||||
raise Exception("未找到 IPA 文件")
|
||||
|
||||
ipa_path = ipa_files[0]
|
||||
await log_streamer.emit(task_id, f"IPA 已导出: {ipa_path.name}")
|
||||
return ipa_path
|
||||
|
||||
|
||||
async def find_dsym(task_id: str, build_dir: Path) -> Path:
|
||||
"""查找 dSYM 文件"""
|
||||
derived_data = build_dir / "build" / "derived_data"
|
||||
if not derived_data.exists():
|
||||
return None
|
||||
|
||||
for dsym in derived_data.rglob("*.dSYM"):
|
||||
return dsym
|
||||
return None
|
||||
|
||||
|
||||
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> tuple:
|
||||
"""上传 IPA,返回 (download_url, qr_code_path)"""
|
||||
await log_streamer.emit_step(task_id, "上传分发平台")
|
||||
|
||||
qr_code_path = None
|
||||
app_guid = config_data.get("APPID", "")
|
||||
|
||||
# 从 config.json 读取上传配置并注入
|
||||
from ..routers.config import load_config
|
||||
full_config = load_config()
|
||||
config_data["_upload_config"] = full_config.get("upload", {})
|
||||
|
||||
# 使用现有的 upload_iap 脚本
|
||||
upload_script = AUTOPACKING_DIR / "upload_iap.py"
|
||||
if not upload_script.exists():
|
||||
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
|
||||
return None, None
|
||||
|
||||
cmd = [
|
||||
"python3",
|
||||
str(upload_script),
|
||||
"--config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
oss_url = None
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
# 解析上传结果
|
||||
if decoded.startswith("UPLOAD_RESULT:"):
|
||||
try:
|
||||
result = json.loads(decoded[len("UPLOAD_RESULT:"):])
|
||||
oss_url = result.get("download_url")
|
||||
qr_code_path = result.get("qr_code_path")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await process.wait()
|
||||
|
||||
# 兜底:查找二维码文件
|
||||
if not qr_code_path and app_guid:
|
||||
expected_qr = PROJECT_ROOT / "build" / f"{app_guid}.png"
|
||||
if expected_qr.exists():
|
||||
qr_code_path = str(expected_qr)
|
||||
|
||||
if process.returncode != 0:
|
||||
await log_streamer.emit_warning(task_id, "上传可能失败")
|
||||
|
||||
return oss_url, qr_code_path
|
||||
86
backend/services/log_streamer.py
Normal file
86
backend/services/log_streamer.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""WebSocket 日志流"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, AsyncGenerator
|
||||
|
||||
|
||||
class LogStreamer:
|
||||
"""日志流管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._queues: Dict[str, asyncio.Queue] = {}
|
||||
self._subscribers: Dict[str, list] = {}
|
||||
|
||||
def create_queue(self, task_id: str) -> asyncio.Queue:
|
||||
"""创建任务的日志队列"""
|
||||
queue = asyncio.Queue(maxsize=1000)
|
||||
self._queues[task_id] = queue
|
||||
return queue
|
||||
|
||||
async def emit(self, task_id: str, message: str, level: str = "info"):
|
||||
"""发送日志消息"""
|
||||
if task_id not in self._queues:
|
||||
return
|
||||
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"level": level,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
try:
|
||||
self._queues[task_id].put_nowait(log_entry)
|
||||
except asyncio.QueueFull:
|
||||
# 队列满了,丢弃最旧的消息
|
||||
try:
|
||||
self._queues[task_id].get_nowait()
|
||||
self._queues[task_id].put_nowait(log_entry)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
async def emit_step(self, task_id: str, step: str):
|
||||
"""发送步骤标记"""
|
||||
await self.emit(task_id, f"[{step}]", level="step")
|
||||
|
||||
async def emit_error(self, task_id: str, error: str):
|
||||
"""发送错误消息"""
|
||||
await self.emit(task_id, error, level="error")
|
||||
|
||||
async def emit_warning(self, task_id: str, warning: str):
|
||||
"""发送警告消息"""
|
||||
await self.emit(task_id, warning, level="warn")
|
||||
|
||||
async def subscribe(self, task_id: str) -> AsyncGenerator[dict, None]:
|
||||
"""订阅任务日志"""
|
||||
queue = self._queues.get(task_id)
|
||||
if not queue:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
if msg is None: # 结束信号
|
||||
break
|
||||
yield msg
|
||||
except asyncio.TimeoutError:
|
||||
# 发送心跳
|
||||
yield {"timestamp": datetime.now().isoformat(), "level": "heartbeat", "message": ""}
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def complete(self, task_id: str):
|
||||
"""标记任务日志结束"""
|
||||
if task_id in self._queues:
|
||||
try:
|
||||
self._queues[task_id].put_nowait(None)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def cleanup(self, task_id: str):
|
||||
"""清理任务日志队列"""
|
||||
self._queues.pop(task_id, None)
|
||||
|
||||
|
||||
# 全局单例
|
||||
log_streamer = LogStreamer()
|
||||
459
deploy.sh
Executable file
459
deploy.sh
Executable file
@ -0,0 +1,459 @@
|
||||
#!/bin/bash
|
||||
# iOS 自动打包服务 — 部署与管理脚本
|
||||
# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop}
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
PID_FILE="$SCRIPT_DIR/.server.pid"
|
||||
WATCHDOG_PID_FILE="$SCRIPT_DIR/.watchdog.pid"
|
||||
LOG_DIR="$SCRIPT_DIR/logs"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
|
||||
# 加载 .env
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
|
||||
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
||||
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
||||
COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒)
|
||||
|
||||
# ========== 颜色输出 ==========
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
# 日志同时写文件和终端
|
||||
log_to_file() {
|
||||
local level="$1"; shift
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >> "$LOG_DIR/watchdog.log"
|
||||
}
|
||||
|
||||
# ========== 环境检查 ==========
|
||||
check_env() {
|
||||
if [ ! -f .env ]; then
|
||||
error ".env 文件不存在"
|
||||
echo " 请执行: cp .env.example .env && vim .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
error "未找到 python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node &>/dev/null; then
|
||||
error "未找到 node"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Xcode
|
||||
if ! command -v xcodebuild &>/dev/null; then
|
||||
warn "未找到 xcodebuild,打包功能将不可用"
|
||||
fi
|
||||
}
|
||||
|
||||
# ========== Python 虚拟环境 ==========
|
||||
setup_venv() {
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
info "创建 Python 虚拟环境..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
source "$VENV_DIR/bin/activate"
|
||||
pip install -r requirements.txt -q
|
||||
}
|
||||
|
||||
# ========== 构建前端 ==========
|
||||
do_build() {
|
||||
# build 只需要 Python 和 Node,不要求 .env
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
error "未找到 python3"; exit 1
|
||||
fi
|
||||
if ! command -v node &>/dev/null; then
|
||||
error "未找到 node"; exit 1
|
||||
fi
|
||||
|
||||
# 首次部署时自动创建 .env
|
||||
if [ ! -f .env ]; then
|
||||
info "首次部署,创建 .env 配置文件..."
|
||||
cp .env.example .env
|
||||
warn "已创建 .env,请按需修改配置(当前为默认值)"
|
||||
fi
|
||||
|
||||
info "安装前端依赖..."
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
npm install --silent
|
||||
|
||||
info "构建前端..."
|
||||
npm run build
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
setup_venv
|
||||
|
||||
info "构建完成"
|
||||
}
|
||||
|
||||
# ========== 启动服务 ==========
|
||||
do_start() {
|
||||
check_env
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
warn "服务已在运行 (PID: $pid)"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# 确保前端已构建
|
||||
if [ ! -d "$SCRIPT_DIR/backend/static" ]; then
|
||||
warn "前端未构建,先执行 build..."
|
||||
do_build
|
||||
fi
|
||||
|
||||
setup_venv
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
info "启动后端服务 (端口: $BACKEND_PORT)..."
|
||||
nohup python3 -m uvicorn backend.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port "$BACKEND_PORT" \
|
||||
--workers 1 \
|
||||
> "$LOG_DIR/server.log" 2>&1 &
|
||||
|
||||
echo $! > "$PID_FILE"
|
||||
sleep 1
|
||||
|
||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
info "服务已启动 (PID: $(cat "$PID_FILE"))"
|
||||
info "访问地址: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost'):$BACKEND_PORT"
|
||||
else
|
||||
error "启动失败,请查看日志: $LOG_DIR/server.log"
|
||||
rm -f "$PID_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ========== 停止服务 ==========
|
||||
do_stop() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
warn "服务未在运行"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
info "停止服务 (PID: $pid)..."
|
||||
kill "$pid"
|
||||
# 等待进程退出
|
||||
for i in $(seq 1 10); do
|
||||
kill -0 "$pid" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
# 还没退出就强杀
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
warn "进程未响应,强制终止..."
|
||||
kill -9 "$pid"
|
||||
fi
|
||||
info "服务已停止"
|
||||
else
|
||||
warn "进程已不存在"
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
}
|
||||
|
||||
# ========== 查看状态 ==========
|
||||
do_status() {
|
||||
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
info "服务运行中 (PID: $(cat "$PID_FILE")), 端口: $BACKEND_PORT"
|
||||
else
|
||||
warn "服务未运行"
|
||||
rm -f "$PID_FILE" 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then
|
||||
info "Watchdog 运行中 (PID: $(cat "$WATCHDOG_PID_FILE")), 检查间隔: ${WATCHDOG_INTERVAL}s"
|
||||
else
|
||||
warn "Watchdog 未运行"
|
||||
rm -f "$WATCHDOG_PID_FILE" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ========== 健康检查 ==========
|
||||
check_health() {
|
||||
# 检查进程是否存在
|
||||
if [ ! -f "$PID_FILE" ] || ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
echo "process_down"
|
||||
return
|
||||
fi
|
||||
|
||||
# 检查 HTTP 是否响应
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--connect-timeout "$WATCHDOG_TIMEOUT" \
|
||||
--max-time "$WATCHDOG_TIMEOUT" \
|
||||
"http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "http_$http_code"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "ok"
|
||||
}
|
||||
|
||||
# ========== Watchdog(前台模式,用于调试) ==========
|
||||
do_watchdog_fg() {
|
||||
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s"
|
||||
info "按 Ctrl+C 停止"
|
||||
|
||||
local restart_count=0
|
||||
local last_restart_time=0
|
||||
|
||||
while true; do
|
||||
local result
|
||||
result=$(check_health)
|
||||
|
||||
if [ "$result" = "ok" ]; then
|
||||
restart_count=0
|
||||
else
|
||||
local now
|
||||
now=$(date +%s)
|
||||
warn "健康检查失败: $result"
|
||||
|
||||
# 冷却判断:连续重启超过上限,暂停一段时间
|
||||
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
local elapsed=$((now - last_restart_time))
|
||||
if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then
|
||||
local wait=$((COOLDOWN_SECONDS - elapsed))
|
||||
warn "连续重启 ${restart_count} 次,冷却中,${wait}s 后重试..."
|
||||
log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s"
|
||||
sleep "$wait"
|
||||
continue
|
||||
else
|
||||
warn "冷却结束,重新尝试..."
|
||||
restart_count=0
|
||||
fi
|
||||
fi
|
||||
|
||||
warn "正在重启服务..."
|
||||
log_to_file "WARN" "Restarting service (cause: $result, attempt: $((restart_count+1)))"
|
||||
|
||||
do_stop
|
||||
sleep 2
|
||||
do_start
|
||||
|
||||
restart_count=$((restart_count + 1))
|
||||
last_restart_time=$(date +%s)
|
||||
|
||||
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
error "已连续重启 ${restart_count} 次,进入冷却..."
|
||||
log_to_file "ERROR" "Entered cooldown after $restart_count restarts"
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep "$WATCHDOG_INTERVAL"
|
||||
done
|
||||
}
|
||||
|
||||
# ========== Watchdog(后台守护) ==========
|
||||
do_watchdog() {
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then
|
||||
warn "Watchdog 已在运行 (PID: $(cat "$WATCHDOG_PID_FILE"))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "启动 Watchdog(后台模式),检查间隔: ${WATCHDOG_INTERVAL}s"
|
||||
|
||||
nohup bash -c "
|
||||
cd '$SCRIPT_DIR'
|
||||
source .env 2>/dev/null
|
||||
exec bash '$(realpath "$0")' _watchdog_loop
|
||||
" >> "$LOG_DIR/watchdog.log" 2>&1 &
|
||||
|
||||
echo $! > "$WATCHDOG_PID_FILE"
|
||||
sleep 1
|
||||
|
||||
if kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then
|
||||
info "Watchdog 已启动 (PID: $(cat "$WATCHDOG_PID_FILE"))"
|
||||
else
|
||||
error "Watchdog 启动失败"
|
||||
rm -f "$WATCHDOG_PID_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 内部循环入口(由后台 watchdog 进程调用)
|
||||
do_watchdog_loop() {
|
||||
local restart_count=0
|
||||
local last_restart_time=0
|
||||
|
||||
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s"
|
||||
|
||||
while true; do
|
||||
local result
|
||||
result=$(check_health)
|
||||
|
||||
if [ "$result" = "ok" ]; then
|
||||
restart_count=0
|
||||
else
|
||||
local now
|
||||
now=$(date +%s)
|
||||
log_to_file "WARN" "Health check failed: $result"
|
||||
|
||||
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
local elapsed=$((now - last_restart_time))
|
||||
if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then
|
||||
local wait=$((COOLDOWN_SECONDS - elapsed))
|
||||
log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s"
|
||||
sleep "$wait"
|
||||
continue
|
||||
else
|
||||
log_to_file "INFO" "Cooldown ended, resetting counter"
|
||||
restart_count=0
|
||||
fi
|
||||
fi
|
||||
|
||||
restart_count=$((restart_count + 1))
|
||||
last_restart_time=$(date +%s)
|
||||
log_to_file "WARN" "Restarting service (attempt: $restart_count)"
|
||||
|
||||
# 停止旧进程
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local old_pid
|
||||
old_pid=$(cat "$PID_FILE")
|
||||
kill "$old_pid" 2>/dev/null
|
||||
sleep 2
|
||||
kill -9 "$old_pid" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# 重新启动
|
||||
source "$VENV_DIR/bin/activate" 2>/dev/null
|
||||
mkdir -p "$LOG_DIR"
|
||||
nohup python3 -m uvicorn backend.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port "$BACKEND_PORT" \
|
||||
--workers 1 \
|
||||
>> "$LOG_DIR/server.log" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
|
||||
sleep 3
|
||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))"
|
||||
else
|
||||
log_to_file "ERROR" "Service restart failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep "$WATCHDOG_INTERVAL"
|
||||
done
|
||||
}
|
||||
|
||||
# ========== 停止 Watchdog ==========
|
||||
do_watchdog_stop() {
|
||||
if [ ! -f "$WATCHDOG_PID_FILE" ]; then
|
||||
warn "Watchdog 未在运行"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$WATCHDOG_PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
info "停止 Watchdog (PID: $pid)..."
|
||||
kill "$pid"
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
info "Watchdog 已停止"
|
||||
else
|
||||
warn "Watchdog 进程已不存在"
|
||||
fi
|
||||
rm -f "$WATCHDOG_PID_FILE"
|
||||
}
|
||||
|
||||
# ========== 运行测试 ==========
|
||||
do_test() {
|
||||
local failed=0
|
||||
|
||||
# 确保依赖已安装
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
setup_venv
|
||||
else
|
||||
source "$VENV_DIR/bin/activate"
|
||||
fi
|
||||
|
||||
if [ ! -d "frontend/node_modules" ]; then
|
||||
cd frontend && npm install --silent && cd ..
|
||||
fi
|
||||
|
||||
# 后端测试
|
||||
info "运行后端测试..."
|
||||
echo "────────────────────────────────────────"
|
||||
python3 -m pytest tests/ -v "$@" || failed=1
|
||||
echo ""
|
||||
|
||||
# 前端测试
|
||||
info "运行前端测试..."
|
||||
echo "────────────────────────────────────────"
|
||||
cd frontend && npx vitest run "$@" || failed=1
|
||||
cd "$SCRIPT_DIR"
|
||||
echo ""
|
||||
|
||||
# 结果
|
||||
if [ "$failed" -eq 0 ]; then
|
||||
info "全部测试通过"
|
||||
else
|
||||
error "存在失败的测试"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ========== 主入口 ==========
|
||||
case "${1:-}" in
|
||||
build) do_build ;;
|
||||
start) do_start ;;
|
||||
stop) do_stop; do_watchdog_stop ;;
|
||||
restart) do_stop; do_start ;;
|
||||
status) do_status ;;
|
||||
test) shift; do_test "$@" ;;
|
||||
watchdog) do_watchdog ;;
|
||||
watchdog-stop) do_watchdog_stop ;;
|
||||
watchdog-fg) do_watchdog_fg ;;
|
||||
_watchdog_loop) do_watchdog_loop ;;
|
||||
*)
|
||||
echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}"
|
||||
echo ""
|
||||
echo " build 构建前端、安装依赖"
|
||||
echo " start 启动服务(后台运行)"
|
||||
echo " stop 停止服务(同时停止 watchdog)"
|
||||
echo " restart 重启服务"
|
||||
echo " status 查看运行状态"
|
||||
echo " test 运行全部测试(后端 + 前端)"
|
||||
echo " watchdog 启动健康监测(后台守护,自动重启故障服务)"
|
||||
echo " watchdog-stop 停止健康监测"
|
||||
echo " watchdog-fg 前台运行健康监测(调试用)"
|
||||
echo ""
|
||||
echo "环境变量(可在 .env 中配置):"
|
||||
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
|
||||
echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)"
|
||||
echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)"
|
||||
echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
145
deploy/install-service.sh
Executable file
145
deploy/install-service.sh
Executable file
@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# 安装/卸载 macOS launchd 服务(主服务 + watchdog 健康监测)
|
||||
# 用法: ./deploy/install-service.sh {install|uninstall}
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# 加载 .env
|
||||
if [ -f "$PROJECT_DIR/.env" ]; then
|
||||
set -a; source "$PROJECT_DIR/.env"; set +a
|
||||
fi
|
||||
|
||||
PORT="${BACKEND_PORT:-8000}"
|
||||
VENV_DIR="$PROJECT_DIR/.venv"
|
||||
LOG_DIR="$PROJECT_DIR/logs"
|
||||
|
||||
PLIST_APP="com.readoor.buildserver"
|
||||
PLIST_WD="com.readoor.buildserver.watchdog"
|
||||
PLIST_APP_TARGET="$HOME/Library/LaunchAgents/$PLIST_APP.plist"
|
||||
PLIST_WD_TARGET="$HOME/Library/LaunchAgents/$PLIST_WD.plist"
|
||||
|
||||
do_install() {
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "请先执行 ./deploy.sh build"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$PROJECT_DIR/backend/static" ]; then
|
||||
echo "请先执行 ./deploy.sh build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# ---- 主服务 plist ----
|
||||
cat > "$PLIST_APP_TARGET" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$PLIST_APP</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$VENV_DIR/bin/python3</string>
|
||||
<string>-m</string>
|
||||
<string>uvicorn</string>
|
||||
<string>backend.main:app</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>--port</string>
|
||||
<string>$PORT</string>
|
||||
<string>--workers</string>
|
||||
<string>1</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$PROJECT_DIR</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG_DIR/server-stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG_DIR/server-stderr.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# ---- Watchdog plist ----
|
||||
cat > "$PLIST_WD_TARGET" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$PLIST_WD</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$PROJECT_DIR/deploy.sh</string>
|
||||
<string>_watchdog_loop</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$PROJECT_DIR</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG_DIR/watchdog-stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG_DIR/watchdog-stderr.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# 加载服务
|
||||
launchctl unload "$PLIST_WD_TARGET" 2>/dev/null || true
|
||||
launchctl unload "$PLIST_APP_TARGET" 2>/dev/null || true
|
||||
launchctl load "$PLIST_APP_TARGET"
|
||||
launchctl load "$PLIST_WD_TARGET"
|
||||
|
||||
echo "服务已安装并启动"
|
||||
echo " 主服务: http://localhost:$PORT"
|
||||
echo " Watchdog: 每 ${WATCHDOG_INTERVAL:-30}s 健康检查,故障自动重启"
|
||||
echo " 日志目录: $LOG_DIR"
|
||||
echo ""
|
||||
echo "卸载命令: $0 uninstall"
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
if [ -f "$PLIST_WD_TARGET" ]; then
|
||||
launchctl unload "$PLIST_WD_TARGET" 2>/dev/null || true
|
||||
rm -f "$PLIST_WD_TARGET"
|
||||
echo "Watchdog 已卸载"
|
||||
fi
|
||||
if [ -f "$PLIST_APP_TARGET" ]; then
|
||||
launchctl unload "$PLIST_APP_TARGET" 2>/dev/null || true
|
||||
rm -f "$PLIST_APP_TARGET"
|
||||
echo "主服务已卸载"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) do_install ;;
|
||||
uninstall) do_uninstall ;;
|
||||
*)
|
||||
echo "用法: $0 {install|uninstall}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iOS 自动打包服务</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3649
frontend/package-lock.json
generated
Normal file
3649
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "build-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
107
frontend/src/App.vue
Normal file
107
frontend/src/App.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<h1>iOS 自动打包服务</h1>
|
||||
<div class="header-right">
|
||||
<nav class="nav">
|
||||
<router-link to="/build">打包</router-link>
|
||||
<router-link to="/history">历史</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/admin">管理</router-link>
|
||||
</nav>
|
||||
<div v-if="!isLoggedIn" class="user-info">
|
||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||
</div>
|
||||
<div v-else class="user-info">
|
||||
<div class="user-avatar">A</div>
|
||||
<span class="user-name">管理员</span>
|
||||
<button class="btn-logout" @click="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<router-view />
|
||||
|
||||
<!-- 登录弹窗 -->
|
||||
<div v-if="showLogin" class="modal-overlay" @click.self="showLogin = false">
|
||||
<div class="login-modal">
|
||||
<h2>管理员登录</h2>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="loginForm.username" type="text" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<button class="login-btn" @click="login">登录</button>
|
||||
<div class="login-hint">默认账号: admin / admin123</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isLoggedIn = ref(false)
|
||||
const showLogin = ref(false)
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(loginForm.value),
|
||||
})
|
||||
if (res.ok) {
|
||||
isLoggedIn.value = true
|
||||
showLogin.value = false
|
||||
localStorage.setItem('isAdmin', 'true')
|
||||
} else {
|
||||
alert('用户名或密码错误')
|
||||
}
|
||||
} catch (e) {
|
||||
alert('登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
isLoggedIn.value = false
|
||||
localStorage.removeItem('isAdmin')
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
if (localStorage.getItem('isAdmin') === 'true') {
|
||||
isLoggedIn.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
|
||||
.header { background: #1a1a2e; color: white; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 20px; }
|
||||
.header-right { display: flex; align-items: center; gap: 20px; }
|
||||
.nav { display: flex; gap: 20px; }
|
||||
.nav a { color: #a0a0a0; text-decoration: none; padding: 8px 16px; border-radius: 6px; }
|
||||
.nav a.router-link-active { background: #16213e; color: white; }
|
||||
.nav a:hover { color: white; }
|
||||
.user-info { display: flex; align-items: center; gap: 10px; }
|
||||
.user-avatar { width: 32px; height: 32px; background: #1890ff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.user-name { font-size: 14px; }
|
||||
.btn-login { background: #1890ff; color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.btn-logout { background: transparent; color: #a0a0a0; border: 1px solid #444; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.btn-logout:hover { border-color: #ff4d4f; color: #ff4d4f; }
|
||||
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.login-modal { background: white; border-radius: 12px; padding: 32px; width: 400px; }
|
||||
.login-modal h2 { font-size: 20px; margin-bottom: 24px; text-align: center; color: #1a1a2e; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group input:focus { outline: none; border-color: #1890ff; }
|
||||
.login-btn { width: 100%; padding: 12px; background: #1890ff; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 8px; }
|
||||
.login-btn:hover { background: #40a9ff; }
|
||||
.login-hint { text-align: center; margin-top: 16px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
128
frontend/src/__tests__/App.test.js
Normal file
128
frontend/src/__tests__/App.test.js
Normal file
@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import App from '../App.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn(k => store[k] || null),
|
||||
setItem: vi.fn((k, v) => { store[k] = v }),
|
||||
removeItem: vi.fn(k => { delete store[k] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
|
||||
|
||||
function createMockRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
}
|
||||
|
||||
describe('App.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.getItem.mockImplementation(k => null)
|
||||
})
|
||||
|
||||
it('显示标题', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('iOS 自动打包服务')
|
||||
})
|
||||
|
||||
it('未登录时显示登录按钮', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('.btn-login').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('已登录时显示退出按钮', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('.btn-logout').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn-login').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('点击登录按钮弹出登录框', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await wrapper.find('.btn-login').trigger('click')
|
||||
expect(wrapper.find('.login-modal').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('登录成功后更新状态', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'admin-token', is_admin: true }),
|
||||
})
|
||||
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('.btn-login').trigger('click')
|
||||
await wrapper.find('.login-modal input[type="text"]').setValue('admin')
|
||||
await wrapper.find('.login-modal input[type="password"]').setValue('admin123')
|
||||
await wrapper.find('.login-btn').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.btn-logout').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('登录失败显示错误', async () => {
|
||||
fetch.mockResolvedValueOnce({ ok: false })
|
||||
window.alert = vi.fn()
|
||||
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('.btn-login').trigger('click')
|
||||
await wrapper.find('.login-modal input[type="text"]').setValue('admin')
|
||||
await wrapper.find('.login-modal input[type="password"]').setValue('wrong')
|
||||
await wrapper.find('.login-btn').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(window.alert).toHaveBeenCalledWith('用户名或密码错误')
|
||||
})
|
||||
|
||||
it('点击退出清除登录状态', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('.btn-logout').trigger('click')
|
||||
expect(wrapper.find('.btn-login').exists()).toBe(true)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('isAdmin')
|
||||
})
|
||||
|
||||
it('已登录时显示管理导航', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const links = wrapper.findAll('.nav a')
|
||||
const texts = links.map(l => l.text())
|
||||
expect(texts).toContain('管理')
|
||||
})
|
||||
})
|
||||
142
frontend/src/__tests__/BuildView.test.js
Normal file
142
frontend/src/__tests__/BuildView.test.js
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import BuildView from '../views/BuildView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock WebSocket as a class
|
||||
class MockWebSocket {
|
||||
constructor(url) { this.url = url; this.onmessage = null; }
|
||||
close() {}
|
||||
}
|
||||
global.WebSocket = MockWebSocket
|
||||
|
||||
describe('BuildView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetch.mockImplementation((url) => {
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [],
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('显示打包配置面板', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('打包配置')
|
||||
expect(wrapper.text()).toContain('选择 App')
|
||||
expect(wrapper.text()).toContain('打包类型')
|
||||
expect(wrapper.text()).toContain('代码分支')
|
||||
expect(wrapper.text()).toContain('代码混淆')
|
||||
})
|
||||
|
||||
it('加载 apps 和 schemes', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
const appOptions = wrapper.findAll('select')[0].findAll('option')
|
||||
expect(appOptions.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('加载分支列表并显示下拉', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
const branchSelect = wrapper.findAll('select').find(s => {
|
||||
const options = s.findAll('option')
|
||||
return options.some(o => o.text() === 'main')
|
||||
})
|
||||
expect(branchSelect).toBeTruthy()
|
||||
})
|
||||
|
||||
it('默认值正确', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.form.build_type).toBe('Ad_Hoc')
|
||||
expect(wrapper.vm.form.obfuscation).toBe(false)
|
||||
expect(wrapper.vm.form.branch).toBe('main')
|
||||
})
|
||||
|
||||
it('提交打包任务', async () => {
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url === '/api/tasks' && opts?.method === 'POST') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'test-id', status: 'pending' }),
|
||||
})
|
||||
}
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [],
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
})
|
||||
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setData({ form: { ...wrapper.vm.form, app_id: '1' } })
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
|
||||
it('未选择 App 时提示', async () => {
|
||||
window.alert = vi.fn()
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
expect(window.alert).toHaveBeenCalledWith('请选择 App')
|
||||
})
|
||||
|
||||
it('显示任务队列', async () => {
|
||||
fetch.mockImplementation((url) => {
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [
|
||||
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch', status: 'pending', created_at: '2024-01-01T00:00:00' },
|
||||
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch', status: 'completed', created_at: '2024-01-01T00:00:00' },
|
||||
],
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
})
|
||||
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
const taskItems = wrapper.findAll('.task-item')
|
||||
expect(taskItems.length).toBe(1) // 只显示未完成的任务(pending),completed 已过滤
|
||||
})
|
||||
|
||||
it('状态文本正确', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
||||
expect(wrapper.vm.statusText('running')).toBe('打包中')
|
||||
expect(wrapper.vm.statusText('completed')).toBe('已完成')
|
||||
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
||||
expect(wrapper.vm.statusText('cancelled')).toBe('已取消')
|
||||
})
|
||||
})
|
||||
164
frontend/src/__tests__/ConfigView.test.js
Normal file
164
frontend/src/__tests__/ConfigView.test.js
Normal file
@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ConfigView from '../views/ConfigView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn(k => store[k] || null),
|
||||
setItem: vi.fn((k, v) => { store[k] = v }),
|
||||
removeItem: vi.fn(k => { delete store[k] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
|
||||
|
||||
function mockFetch(url) {
|
||||
const responses = {
|
||||
'/api/config/apps': { '1': { name: '测试App', server: '测试环境', AppGuid: 'guid' } },
|
||||
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
|
||||
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
||||
'/api/config/branches': ['main', 'dev'],
|
||||
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
||||
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('ConfigView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
fetch.mockImplementation(mockFetch)
|
||||
})
|
||||
|
||||
it('未登录时显示权限提示', async () => {
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('需要管理员权限')
|
||||
})
|
||||
|
||||
it('已登录时显示管理页面', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('配置管理')
|
||||
})
|
||||
|
||||
it('显示侧边栏菜单', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const menuItems = wrapper.findAll('.sidebar-menu li')
|
||||
const texts = menuItems.map(li => li.text())
|
||||
expect(texts).toContain('服务器环境')
|
||||
expect(texts).toContain('Apps 配置')
|
||||
expect(texts).toContain('Schemes 配置')
|
||||
expect(texts).toContain('分支管理')
|
||||
expect(texts).toContain('打包设置')
|
||||
})
|
||||
|
||||
it('默认显示服务器环境 tab', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('服务器环境配置')
|
||||
})
|
||||
|
||||
it('切换到分支管理 tab', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
await branchesMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('main')
|
||||
expect(wrapper.text()).toContain('dev')
|
||||
})
|
||||
|
||||
it('添加分支', async () => {
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url === '/api/config/branches' && opts?.method === 'POST') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||
}
|
||||
return mockFetch(url)
|
||||
})
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
await branchesMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const input = wrapper.find('input[placeholder="输入分支名称"]')
|
||||
await input.setValue('new-branch')
|
||||
|
||||
const addBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '添加分支')
|
||||
await addBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/config/branches', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
|
||||
it('删除分支', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url.includes('/api/config/branches/') && opts?.method === 'DELETE') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||
}
|
||||
return mockFetch(url)
|
||||
})
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
await branchesMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const deleteBtns = wrapper.findAll('.action-btn.delete')
|
||||
if (deleteBtns.length > 0) {
|
||||
await deleteBtns[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(window.confirm).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('保存打包设置', async () => {
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url === '/api/config/build' && opts?.method === 'PUT') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||
}
|
||||
return mockFetch(url)
|
||||
})
|
||||
window.alert = vi.fn()
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||
await buildMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存设置')
|
||||
await saveBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
||||
})
|
||||
})
|
||||
179
frontend/src/__tests__/HistoryView.test.js
Normal file
179
frontend/src/__tests__/HistoryView.test.js
Normal file
@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import HistoryView from '../views/HistoryView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
window.open = vi.fn()
|
||||
|
||||
const mockTasks = [
|
||||
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' },
|
||||
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败' },
|
||||
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' },
|
||||
]
|
||||
|
||||
function createMockRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/build', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('HistoryView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetch.mockResolvedValue({
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
})
|
||||
})
|
||||
|
||||
it('显示打包历史标题', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('打包历史')
|
||||
})
|
||||
|
||||
it('加载并显示任务列表', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2) // App2(failed) 默认隐藏
|
||||
})
|
||||
|
||||
it('显示任务信息', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('App1')
|
||||
// App2 是 failed 状态,默认隐藏
|
||||
expect(wrapper.text()).not.toContain('App2')
|
||||
expect(wrapper.text()).toContain('Ad_Hoc')
|
||||
})
|
||||
|
||||
it('默认隐藏失败和已取消的任务', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// 只显示 App1(completed)和 App3(pending),App2(failed)被隐藏
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2)
|
||||
expect(wrapper.text()).toContain('App1')
|
||||
expect(wrapper.text()).toContain('App3')
|
||||
expect(wrapper.text()).toContain('等待中')
|
||||
})
|
||||
|
||||
it('按打包类型过滤', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[0].setValue('Ad_Hoc')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2) // App1 和 App3
|
||||
})
|
||||
|
||||
it('按状态过滤', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[1].setValue('failed')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(1) // App2
|
||||
})
|
||||
|
||||
it('已完成任务显示下载按钮', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// 第一行(App1, completed)应该有 dSYM 和下载按钮
|
||||
const firstRow = wrapper.findAll('tbody tr')[0]
|
||||
const buttons = firstRow.findAll('.action-btn')
|
||||
const buttonTexts = buttons.map(b => b.text())
|
||||
expect(buttonTexts).toContain('dSYM')
|
||||
expect(buttonTexts).toContain('下载')
|
||||
expect(buttonTexts).toContain('二维码')
|
||||
})
|
||||
|
||||
it('空列表显示提示', async () => {
|
||||
fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('暂无打包记录')
|
||||
})
|
||||
|
||||
it('状态文本正确', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
||||
expect(wrapper.vm.statusText('running')).toBe('打包中')
|
||||
expect(wrapper.vm.statusText('completed')).toBe('已完成')
|
||||
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
||||
})
|
||||
|
||||
it('点击查看日志跳转', async () => {
|
||||
const router = createMockRouter()
|
||||
router.push = vi.fn()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
|
||||
await logBtn.trigger('click')
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } })
|
||||
})
|
||||
|
||||
it('点击二维码弹出弹窗', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码')
|
||||
await qrBtn.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.qr-modal').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('下载二维码')
|
||||
})
|
||||
})
|
||||
42
frontend/src/composables/useWebSocket.js
Normal file
42
frontend/src/composables/useWebSocket.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useWebSocket(taskId) {
|
||||
const logs = ref([])
|
||||
const connected = ref(false)
|
||||
let ws = null
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.level !== 'heartbeat') {
|
||||
logs.value.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
connected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(disconnect)
|
||||
|
||||
return { logs, connected, connect, disconnect }
|
||||
}
|
||||
7
frontend/src/main.js
Normal file
7
frontend/src/main.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
18
frontend/src/router/index.js
Normal file
18
frontend/src/router/index.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import BuildView from '../views/BuildView.vue'
|
||||
import HistoryView from '../views/HistoryView.vue'
|
||||
import ConfigView from '../views/ConfigView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/build' },
|
||||
{ path: '/build', name: 'Build', component: BuildView },
|
||||
{ path: '/history', name: 'History', component: HistoryView },
|
||||
{ path: '/admin', name: 'Admin', component: ConfigView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
338
frontend/src/views/BuildView.vue
Normal file
338
frontend/src/views/BuildView.vue
Normal file
@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="build-page">
|
||||
<!-- 左侧配置面板 -->
|
||||
<div class="config-panel">
|
||||
<h2>打包配置</h2>
|
||||
<div class="form-group">
|
||||
<label>选择 App</label>
|
||||
<select v-model="form.app_id">
|
||||
<option value="">请选择...</option>
|
||||
<option v-for="(app, id) in apps" :key="id" :value="id">
|
||||
{{ app.server }} - {{ app.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包类型</label>
|
||||
<select v-model="form.build_type">
|
||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
||||
<option value="App_Store">App_Store</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>选择 Scheme</label>
|
||||
<select v-model="form.scheme_id">
|
||||
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
|
||||
{{ scheme.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>代码分支</label>
|
||||
<select v-model="form.branch">
|
||||
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>代码混淆</label>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="obfuscation" v-model="form.obfuscation">
|
||||
<label for="obfuscation">启用混淆</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="submitTask" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : '开始打包' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧日志和任务列表 -->
|
||||
<div class="right-panel">
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<h3>实时日志 {{ currentTaskId ? `- ${currentTaskId}` : '' }}</h3>
|
||||
<button v-if="currentTaskId" class="btn btn-danger" style="width: auto; padding: 6px 12px;" @click="cancelTask">
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
<div class="log-content" ref="logContainer">
|
||||
<div v-for="(log, i) in logs" :key="i" :class="['log-line', log.level]">
|
||||
[{{ formatTime(log.timestamp) }}] {{ log.message }}
|
||||
</div>
|
||||
<div v-if="!logs.length && !currentTaskId" class="log-line info" style="color: #666;">
|
||||
选择任务或创建新任务查看日志
|
||||
</div>
|
||||
<div v-else-if="!logs.length" class="log-line info" style="color: #666;">
|
||||
正在连接日志流...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打包结果 -->
|
||||
<div v-if="completedTask" class="result-panel">
|
||||
<h3>打包结果</h3>
|
||||
<div class="result-info">
|
||||
<div class="result-row">
|
||||
<span class="result-label">App</span>
|
||||
<span>{{ completedTask.app_name }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">类型</span>
|
||||
<span>{{ completedTask.build_type }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">状态</span>
|
||||
<span :class="['task-status', `status-${completedTask.status}`]">{{ statusText(completedTask.status) }}</span>
|
||||
</div>
|
||||
<div v-if="completedTask.error_message" class="result-row">
|
||||
<span class="result-label">错误信息</span>
|
||||
<span class="error-msg">{{ completedTask.error_message }}</span>
|
||||
</div>
|
||||
<div v-if="completedTask.oss_url" class="result-row">
|
||||
<span class="result-label">下载链接</span>
|
||||
<a :href="completedTask.oss_url" target="_blank" class="download-link">点击下载 IPA</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="completedTask.qr_code_path" class="qr-section">
|
||||
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
||||
<p class="qr-hint">扫码下载安装</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<h3>任务队列</h3>
|
||||
<div v-for="task in tasks" :key="task.id" class="task-item" @click="selectTask(task.id)">
|
||||
<div class="task-info">
|
||||
<div class="app-name">{{ task.app_name }}</div>
|
||||
<div class="task-meta">{{ task.build_type }} | {{ task.scheme_name }} | {{ formatTime(task.created_at) }}</div>
|
||||
</div>
|
||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||
</div>
|
||||
<div v-if="!tasks.length" style="text-align: center; color: #999; padding: 20px;">
|
||||
暂无任务
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||||
|
||||
const apps = ref({})
|
||||
const schemes = ref({})
|
||||
const branches = ref(['main'])
|
||||
const tasks = ref([])
|
||||
const form = ref({
|
||||
app_id: '',
|
||||
build_type: 'Ad_Hoc',
|
||||
scheme_id: '1',
|
||||
obfuscation: false,
|
||||
branch: 'main',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const currentTaskId = ref(null)
|
||||
const completedTask = ref(null)
|
||||
const logs = ref([])
|
||||
const logContainer = ref(null)
|
||||
let activeWs = null
|
||||
|
||||
const fetchTaskDetail = async (taskId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`)
|
||||
if (res.ok) {
|
||||
completedTask.value = await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const connectWs = (taskId) => {
|
||||
if (activeWs) {
|
||||
activeWs.close()
|
||||
activeWs = null
|
||||
}
|
||||
logs.value = []
|
||||
completedTask.value = null
|
||||
if (!taskId) return
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
||||
activeWs = ws
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.level !== 'heartbeat') {
|
||||
logs.value.push(msg)
|
||||
nextTick(() => {
|
||||
if (logContainer.value) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
// 检测打包完成或失败,获取任务详情
|
||||
if (msg.level === 'step' && msg.message.includes('打包完成')) {
|
||||
fetchTaskDetail(taskId)
|
||||
refreshTasks()
|
||||
}
|
||||
if (msg.level === 'error' || (msg.level === 'step' && msg.message.includes('打包失败'))) {
|
||||
fetchTaskDetail(taskId)
|
||||
refreshTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => currentTaskId.value, (newId) => {
|
||||
connectWs(newId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (activeWs) {
|
||||
activeWs.close()
|
||||
activeWs = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
|
||||
fetch('/api/apps'),
|
||||
fetch('/api/schemes'),
|
||||
fetch('/api/branches'),
|
||||
fetch('/api/tasks'),
|
||||
])
|
||||
apps.value = await appsRes.json()
|
||||
schemes.value = await schemesRes.json()
|
||||
branches.value = await branchesRes.json()
|
||||
const allTasks = await tasksRes.json()
|
||||
tasks.value = allTasks.filter(t =>
|
||||
t.status === 'pending' || t.status === 'running' || t.id === currentTaskId.value
|
||||
)
|
||||
// 确保默认分支在列表中
|
||||
if (!branches.value.includes(form.value.branch)) {
|
||||
form.value.branch = branches.value[0] || 'main'
|
||||
}
|
||||
})
|
||||
|
||||
const submitTask = async () => {
|
||||
if (!form.value.app_id) {
|
||||
alert('请选择 App')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
if (res.ok) {
|
||||
const task = await res.json()
|
||||
currentTaskId.value = task.id
|
||||
tasks.value.unshift(task)
|
||||
} else {
|
||||
alert('提交失败')
|
||||
}
|
||||
} catch (e) {
|
||||
alert('提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectTask = (taskId) => {
|
||||
currentTaskId.value = taskId
|
||||
completedTask.value = null
|
||||
}
|
||||
|
||||
const cancelTask = async () => {
|
||||
if (!currentTaskId.value) return
|
||||
if (!confirm('确定取消当前任务?')) return
|
||||
const res = await fetch(`/api/tasks/${currentTaskId.value}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
alert(err.detail || '取消失败')
|
||||
}
|
||||
refreshTasks()
|
||||
// 刷新任务详情以更新状态
|
||||
if (completedTask.value?.id === currentTaskId.value) {
|
||||
fetchTaskDetail(currentTaskId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshTasks = async () => {
|
||||
const res = await fetch('/api/tasks?limit=50')
|
||||
const all = await res.json()
|
||||
// 显示未完成的任务 + 当前选中的任务
|
||||
tasks.value = all.filter(t =>
|
||||
t.status === 'pending' || t.status === 'running' || t.id === currentTaskId.value
|
||||
)
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
return d.toLocaleTimeString()
|
||||
}
|
||||
|
||||
const statusText = (s) => {
|
||||
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.build-page { display: grid; grid-template-columns: 360px 1fr; gap: 24px; }
|
||||
.config-panel { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.config-panel h2 { font-size: 16px; margin-bottom: 16px; color: #1a1a2e; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group select, .form-group input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group select:focus { outline: none; border-color: #1890ff; }
|
||||
.checkbox-group { display: flex; align-items: center; gap: 8px; }
|
||||
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; width: 100%; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||
.btn-danger { background: #ff4d4f; color: white; }
|
||||
|
||||
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
|
||||
.log-panel { background: #1a1a2e; border-radius: 12px; flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.log-header { padding: 12px 16px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; }
|
||||
.log-header h3 { color: #a0a0a0; font-size: 14px; }
|
||||
.log-content { flex: 1; padding: 16px; font-family: 'Monaco', 'Menlo', monospace; font-size: 12px; color: #00ff00; overflow-y: auto; line-height: 1.6; }
|
||||
.log-line { margin-bottom: 4px; }
|
||||
.log-line.info { color: #00ff00; }
|
||||
.log-line.warn { color: #ffcc00; }
|
||||
.log-line.error { color: #ff4d4f; }
|
||||
.log-line.step { color: #1890ff; font-weight: bold; }
|
||||
|
||||
.task-list { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); max-height: 300px; overflow-y: auto; }
|
||||
.task-list h3 { font-size: 14px; margin-bottom: 12px; color: #1a1a2e; }
|
||||
.task-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 8px; cursor: pointer; }
|
||||
.task-item:hover { border-color: #1890ff; }
|
||||
.task-info { flex: 1; }
|
||||
.task-info .app-name { font-weight: 500; color: #1a1a2e; }
|
||||
.task-info .task-meta { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-pending { background: #f0f0f0; color: #666; }
|
||||
.status-running { background: #e6f7ff; color: #1890ff; }
|
||||
.status-completed { background: #f6ffed; color: #52c41a; }
|
||||
.status-failed { background: #fff2f0; color: #ff4d4f; }
|
||||
.status-cancelled { background: #f0f0f0; color: #999; }
|
||||
|
||||
.result-panel { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.result-panel h3 { font-size: 14px; margin-bottom: 12px; color: #1a1a2e; }
|
||||
.result-info { margin-bottom: 16px; }
|
||||
.result-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
||||
.result-row:last-child { border-bottom: none; }
|
||||
.result-label { width: 80px; color: #999; font-size: 13px; flex-shrink: 0; }
|
||||
.download-link { color: #1890ff; text-decoration: none; }
|
||||
.download-link:hover { text-decoration: underline; }
|
||||
.qr-section { text-align: center; padding: 16px; background: #fafafa; border-radius: 8px; }
|
||||
.qr-image { width: 180px; height: 180px; border: 1px solid #f0f0f0; border-radius: 8px; }
|
||||
.qr-hint { margin-top: 8px; font-size: 13px; color: #999; }
|
||||
.error-msg { color: #ff4d4f; font-size: 13px; word-break: break-all; }
|
||||
</style>
|
||||
804
frontend/src/views/ConfigView.vue
Normal file
804
frontend/src/views/ConfigView.vue
Normal file
@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="!isLoggedIn" class="access-denied">
|
||||
<h3>需要管理员权限</h3>
|
||||
<p>请先登录管理员账号以访问配置管理页面</p>
|
||||
<button class="btn-login" @click="$root.showLogin = true">登录管理员账号</button>
|
||||
</div>
|
||||
<div v-else class="admin-page">
|
||||
<div class="sidebar">
|
||||
<h3>配置管理</h3>
|
||||
<ul class="sidebar-menu">
|
||||
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
||||
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
||||
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
||||
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
||||
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||
<li :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<!-- 服务器环境配置 -->
|
||||
<div v-if="tab === 'servers'">
|
||||
<h2>服务器环境配置</h2>
|
||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理服务器环境和对应的 API 地址、关联域名等配置</p>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openServerModal()">+ 新增环境</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>环境名称</th>
|
||||
<th>API 地址</th>
|
||||
<th>Associated Domains</th>
|
||||
<th>Universal Link</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(server, name) in servers" :key="name">
|
||||
<td><strong>{{ name }}</strong></td>
|
||||
<td>{{ server.api }}</td>
|
||||
<td>{{ server.assDom }}</td>
|
||||
<td>{{ server.universalLink }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openServerModal(name, server)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteServer(name)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Apps 配置 -->
|
||||
<div v-if="tab === 'apps'">
|
||||
<h2>Apps 配置</h2>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openAppModal()">+ 新增 App</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>环境</th>
|
||||
<th>AppGuid</th>
|
||||
<th>证书</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(app, id) in apps" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ app.name }}</td>
|
||||
<td>{{ app.server }}</td>
|
||||
<td>{{ app.AppGuid || app.AppId }}</td>
|
||||
<td>{{ Object.keys(app.certificates || {}).join(', ') || '-' }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openAppModal(id, app)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteApp(id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Schemes 配置 -->
|
||||
<div v-if="tab === 'schemes'">
|
||||
<h2>Schemes 配置</h2>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openSchemeModal()">+ 新增 Scheme</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>OSS 目录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(scheme, id) in schemes" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ scheme.name }}</td>
|
||||
<td>{{ scheme.ossFloder }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openSchemeModal(id, scheme)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteScheme(id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分支管理 -->
|
||||
<div v-if="tab === 'branches'">
|
||||
<h2>分支管理</h2>
|
||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理可打包的代码分支,打包时从对应分支目录获取源码</p>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
|
||||
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
|
||||
</div>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分支名称</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in branches" :key="b">
|
||||
<td><strong>{{ b }}</strong></td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!branches.length">
|
||||
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 上传配置 -->
|
||||
<div v-if="tab === 'upload'">
|
||||
<h2>上传配置</h2>
|
||||
<div style="max-width: 600px;">
|
||||
<div class="form-group">
|
||||
<label>上传方式</label>
|
||||
<select v-model="uploadConfig.mode">
|
||||
<option value="oss">阿里云 OSS</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- OSS 配置 -->
|
||||
<div v-if="uploadConfig.mode === 'oss'" class="config-section">
|
||||
<h4 class="section-title">阿里云 OSS</h4>
|
||||
<div class="form-group">
|
||||
<label>AccessKey ID</label>
|
||||
<input v-model="uploadConfig.oss.access_key_id" type="text" placeholder="LTAI...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>AccessKey Secret</label>
|
||||
<input v-model="uploadConfig.oss.access_key_secret" type="password" placeholder="密钥">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint</label>
|
||||
<input v-model="uploadConfig.oss.endpoint" type="text" placeholder="oss-cn-beijing.aliyuncs.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bucket 名称</label>
|
||||
<input v-model="uploadConfig.oss.bucket_name" type="text" placeholder="my-bucket">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>访问地址(Base URL)</label>
|
||||
<input v-model="uploadConfig.oss.base_url" type="text" placeholder="https://my-bucket.oss-cn-beijing.aliyuncs.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV 配置 -->
|
||||
<div v-if="uploadConfig.mode === 'webdav'" class="config-section">
|
||||
<h4 class="section-title">WebDAV</h4>
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="uploadConfig.webdav.server_url" type="text" placeholder="https://dav.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="uploadConfig.webdav.username" type="text" placeholder="留空则无需认证">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="uploadConfig.webdav.password" type="password" placeholder="密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>远程路径</label>
|
||||
<input v-model="uploadConfig.webdav.base_path" type="text" placeholder="/ios-builds">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>公开访问地址</label>
|
||||
<input v-model="uploadConfig.webdav.public_url" type="text" placeholder="https://download.example.com/ios-builds">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">iOS 安装需要 HTTPS 公开链接,需配置 Nginx 反代或公开目录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 钉钉通知 -->
|
||||
<div class="config-section">
|
||||
<h4 class="section-title">钉钉通知</h4>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="uploadConfig.dingtalk.enabled" style="width: 16px; height: 16px;">
|
||||
启用钉钉通知
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="uploadConfig.dingtalk.enabled">
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input v-model="uploadConfig.dingtalk.webhook_url" type="text" placeholder="https://oapi.dingtalk.com/robot/send?access_token=...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>加签密钥(可选)</label>
|
||||
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveUploadConfig">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打包设置 -->
|
||||
<div v-if="tab === 'build'">
|
||||
<h2>打包设置</h2>
|
||||
<div style="max-width: 500px;">
|
||||
<div class="form-group">
|
||||
<label>最大并行打包数</label>
|
||||
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包目录保留时间(小时)</label>
|
||||
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包基础目录</label>
|
||||
<input type="text" v-model="buildSettings.build_base_dir">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON 编辑 -->
|
||||
<div v-if="tab === 'json'">
|
||||
<h2>JSON 编辑</h2>
|
||||
<div style="background: #1a1a2e; border-radius: 8px; padding: 16px;">
|
||||
<textarea v-model="jsonContent" style="width: 100%; height: 500px; background: transparent; border: none; color: #00ff00; font-family: 'Monaco', monospace; font-size: 12px; resize: vertical;"></textarea>
|
||||
</div>
|
||||
<div style="margin-top: 16px; display: flex; gap: 12px;">
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveJson">保存 JSON</button>
|
||||
<button class="action-btn" style="padding: 10px 24px;" @click="formatJson">格式化</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务器环境编辑弹窗 -->
|
||||
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingServerName ? '编辑' : '新增' }} 服务器环境</h3>
|
||||
<button class="modal-close" @click="showServerModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>环境名称 *</label>
|
||||
<input v-model="serverForm.name" type="text" placeholder="例如:测试环境" :disabled="!!editingServerName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API 地址 *</label>
|
||||
<input v-model="serverForm.api" type="text" placeholder="https://api3-dev.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Associated Domains</label>
|
||||
<input v-model="serverForm.assDom" type="text" placeholder="applinks:dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Universal Link</label>
|
||||
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App 编辑弹窗 -->
|
||||
<div v-if="showAppModal" class="modal-overlay" @click.self="showAppModal = false">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingAppId ? '编辑' : '新增' }} App</h3>
|
||||
<button class="modal-close" @click="showAppModal = false">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<h4 class="section-title">基本信息</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>App 名称 *</label>
|
||||
<input v-model="appForm.name" type="text" placeholder="例如:上财云津">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器环境 *</label>
|
||||
<select v-model="appForm.server" @change="onServerChange">
|
||||
<option value="">请选择环境</option>
|
||||
<option v-for="(server, name) in servers" :key="name" :value="name">
|
||||
{{ name }} - {{ server.api }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>AppGuid *</label>
|
||||
<input v-model="appForm.AppGuid" type="text" placeholder="应用唯一标识">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API 地址 *</label>
|
||||
<input v-model="appForm.API" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联配置 -->
|
||||
<h4 class="section-title">关联配置</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Associated Domains</label>
|
||||
<input v-model="appForm.AssDom" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Universal Link</label>
|
||||
<input v-model="appForm.UniversalLink" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>微信登录 AppID</label>
|
||||
<input v-model="appForm.weixinlogin" type="text" placeholder="wx...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>微信支付 AppID</label>
|
||||
<input v-model="appForm.weixinpay" type="text" placeholder="wx...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>腾讯 AppID</label>
|
||||
<input v-model="appForm.tencent" type="text" placeholder="tencent...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>阿里云 License Key</label>
|
||||
<input v-model="appForm.AlivcLicenseKey" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 证书配置 -->
|
||||
<h4 class="section-title">证书配置</h4>
|
||||
<div v-for="certType in ['Ad_Hoc', 'App_Store']" :key="certType" class="cert-section">
|
||||
<div class="cert-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" :checked="appForm.certificates && appForm.certificates[certType]" @change="toggleCert(certType)">
|
||||
{{ certType }} 证书
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="appForm.certificates && appForm.certificates[certType]" class="cert-fields">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Bundle ID</label>
|
||||
<input v-model="appForm.certificates[certType].name" type="text" placeholder="cn.example.app">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>证书名称</label>
|
||||
<input v-model="appForm.certificates[certType].cer" type="text" placeholder="iPhone Distribution: ...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Provisioning Profile 路径</label>
|
||||
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题目录</label>
|
||||
<input v-model="appForm.certificates[certType].theme" type="text" placeholder="AutoPacking/ymh">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showAppModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveApp">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheme 编辑弹窗 -->
|
||||
<div v-if="showSchemeModal" class="modal-overlay" @click.self="showSchemeModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingSchemeId ? '编辑' : '新增' }} Scheme</h3>
|
||||
<button class="modal-close" @click="showSchemeModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scheme 名称 *</label>
|
||||
<input v-model="schemeForm.name" type="text" placeholder="例如:readoor31">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>OSS 目录 *</label>
|
||||
<input v-model="schemeForm.ossFloder" type="text" placeholder="例如:readoor">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showSchemeModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveScheme">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isLoggedIn = ref(localStorage.getItem('isAdmin') === 'true')
|
||||
const tab = ref('servers')
|
||||
const apps = ref({})
|
||||
const schemes = ref({})
|
||||
const servers = ref({})
|
||||
const branches = ref([])
|
||||
const newBranch = ref('')
|
||||
const buildSettings = ref({})
|
||||
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
||||
const jsonContent = ref('{}')
|
||||
|
||||
const showServerModal = ref(false)
|
||||
const editingServerName = ref(null)
|
||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '' })
|
||||
|
||||
const showAppModal = ref(false)
|
||||
const editingAppId = ref(null)
|
||||
const appForm = ref({})
|
||||
|
||||
const showSchemeModal = ref(false)
|
||||
const editingSchemeId = ref(null)
|
||||
const schemeForm = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes] = await Promise.all([
|
||||
fetch('/api/config/apps'),
|
||||
fetch('/api/config/schemes'),
|
||||
fetch('/api/config/servers'),
|
||||
fetch('/api/config/branches'),
|
||||
fetch('/api/config/build'),
|
||||
fetch('/api/config/upload'),
|
||||
fetch('/api/config'),
|
||||
])
|
||||
apps.value = await appsRes.json()
|
||||
schemes.value = await schemesRes.json()
|
||||
servers.value = await serversRes.json()
|
||||
branches.value = await branchesRes.json()
|
||||
buildSettings.value = await buildRes.json()
|
||||
uploadConfig.value = await uploadRes.json()
|
||||
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
||||
}
|
||||
|
||||
// 服务器环境管理
|
||||
const openServerModal = (name = null, server = null) => {
|
||||
editingServerName.value = name
|
||||
serverForm.value = server ? { ...server, name } : { name: '', api: '', assDom: '', universalLink: '' }
|
||||
showServerModal.value = true
|
||||
}
|
||||
|
||||
const saveServer = async () => {
|
||||
if (!serverForm.value.name || !serverForm.value.api) {
|
||||
alert('请填写必填字段:环境名称、API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingServerName.value) {
|
||||
const res = await fetch(`/api/config/servers/${encodeURIComponent(editingServerName.value)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverForm.value),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '保存失败')
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/config/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverForm.value),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '创建失败')
|
||||
}
|
||||
}
|
||||
showServerModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteServer = async (name) => {
|
||||
if (!confirm(`确定删除环境「${name}」?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/config/servers/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '删除失败')
|
||||
}
|
||||
await loadData()
|
||||
alert('删除成功')
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// App 相关
|
||||
const openAppModal = (id = null, app = null) => {
|
||||
editingAppId.value = id
|
||||
if (app) {
|
||||
appForm.value = JSON.parse(JSON.stringify(app))
|
||||
if (!appForm.value.certificates) appForm.value.certificates = {}
|
||||
} else {
|
||||
appForm.value = {
|
||||
name: '',
|
||||
server: '',
|
||||
AppGuid: '',
|
||||
API: '',
|
||||
AssDom: '',
|
||||
UniversalLink: '',
|
||||
weixinlogin: '',
|
||||
weixinpay: '',
|
||||
tencent: '',
|
||||
AlivcLicenseKey: '',
|
||||
certificates: {},
|
||||
}
|
||||
}
|
||||
showAppModal.value = true
|
||||
}
|
||||
|
||||
const onServerChange = () => {
|
||||
const serverName = appForm.value.server
|
||||
if (serverName && servers.value[serverName]) {
|
||||
const server = servers.value[serverName]
|
||||
appForm.value.API = server.api || ''
|
||||
appForm.value.AssDom = server.assDom || ''
|
||||
appForm.value.UniversalLink = server.universalLink || ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCert = (certType) => {
|
||||
if (!appForm.value.certificates) appForm.value.certificates = {}
|
||||
if (appForm.value.certificates[certType]) {
|
||||
delete appForm.value.certificates[certType]
|
||||
} else {
|
||||
appForm.value.certificates[certType] = {
|
||||
name: '',
|
||||
pro: '',
|
||||
cer: '',
|
||||
theme: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveApp = async () => {
|
||||
if (!appForm.value.name || !appForm.value.AppGuid) {
|
||||
alert('请填写必填字段:App 名称、AppGuid')
|
||||
return
|
||||
}
|
||||
|
||||
// 清理空证书
|
||||
if (appForm.value.certificates) {
|
||||
Object.keys(appForm.value.certificates).forEach(key => {
|
||||
if (!appForm.value.certificates[key].name) {
|
||||
delete appForm.value.certificates[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清理空值
|
||||
const cleanData = { ...appForm.value }
|
||||
Object.keys(cleanData).forEach(key => {
|
||||
if (cleanData[key] === '' || cleanData[key] === null || cleanData[key] === undefined) {
|
||||
delete cleanData[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
let res
|
||||
if (editingAppId.value) {
|
||||
res = await fetch(`/api/config/apps/${editingAppId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cleanData),
|
||||
})
|
||||
} else {
|
||||
res = await fetch('/api/config/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cleanData),
|
||||
})
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '保存失败' }))
|
||||
throw new Error(err.detail || '保存失败')
|
||||
}
|
||||
showAppModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApp = async (id) => {
|
||||
if (!confirm('确定删除此 App?')) return
|
||||
await fetch(`/api/config/apps/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// Scheme 相关
|
||||
const openSchemeModal = (id = null, scheme = null) => {
|
||||
editingSchemeId.value = id
|
||||
schemeForm.value = scheme ? { ...scheme } : { name: '', ossFloder: '' }
|
||||
showSchemeModal.value = true
|
||||
}
|
||||
|
||||
const saveScheme = async () => {
|
||||
if (!schemeForm.value.name || !schemeForm.value.ossFloder) {
|
||||
alert('请填写必填字段:名称、OSS 目录')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingSchemeId.value) {
|
||||
const res = await fetch(`/api/config/schemes/${editingSchemeId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schemeForm.value),
|
||||
})
|
||||
if (!res.ok) throw new Error('保存失败')
|
||||
} else {
|
||||
const res = await fetch('/api/config/schemes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schemeForm.value),
|
||||
})
|
||||
if (!res.ok) throw new Error('创建失败')
|
||||
}
|
||||
showSchemeModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteScheme = async (id) => {
|
||||
if (!confirm('确定删除此 Scheme?')) return
|
||||
await fetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// 分支管理
|
||||
const addBranch = async () => {
|
||||
const name = newBranch.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
const res = await fetch('/api/config/branches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '添加失败')
|
||||
}
|
||||
newBranch.value = ''
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
alert('添加失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBranch = async (name) => {
|
||||
if (!confirm(`确定删除分支「${name}」?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('删除失败')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他
|
||||
const saveBuildSettings = async () => {
|
||||
await fetch('/api/config/build', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildSettings.value),
|
||||
})
|
||||
alert('设置已保存')
|
||||
}
|
||||
|
||||
const saveUploadConfig = async () => {
|
||||
try {
|
||||
await fetch('/api/config/upload', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(uploadConfig.value),
|
||||
})
|
||||
alert('上传配置已保存')
|
||||
} catch (e) {
|
||||
alert('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveJson = async () => {
|
||||
try {
|
||||
const config = JSON.parse(jsonContent.value)
|
||||
await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
await loadData()
|
||||
alert('配置已保存')
|
||||
} catch (e) {
|
||||
alert('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const config = JSON.parse(jsonContent.value)
|
||||
jsonContent.value = JSON.stringify(config, null, 2)
|
||||
} catch (e) {
|
||||
alert('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.access-denied { text-align: center; padding: 60px 20px; background: white; border-radius: 12px; }
|
||||
.access-denied h3 { font-size: 18px; color: #666; margin-bottom: 8px; }
|
||||
.access-denied p { color: #999; margin-bottom: 20px; }
|
||||
.btn-login { background: #1890ff; color: white; border: none; padding: 10px 32px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.admin-page { display: grid; grid-template-columns: 200px 1fr; gap: 24px; min-height: calc(100vh - 120px); }
|
||||
.sidebar { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.sidebar h3 { font-size: 13px; color: #999; margin-bottom: 12px; text-transform: uppercase; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { padding: 10px 12px; border-radius: 6px; cursor: pointer; color: #333; margin-bottom: 4px; }
|
||||
.sidebar-menu li:hover { background: #f5f5f5; }
|
||||
.sidebar-menu li.active { background: #e6f7ff; color: #1890ff; }
|
||||
.main-content { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.main-content h2 { font-size: 18px; margin-bottom: 20px; color: #1a1a2e; }
|
||||
.config-table { width: 100%; border-collapse: collapse; }
|
||||
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table tr:hover { background: #fafafa; }
|
||||
.action-btns { display: flex; gap: 8px; }
|
||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||
.action-btn.delete:hover { border-color: #ff4d4f; color: #ff4d4f; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group input:focus, .form-group select:focus { outline: none; border-color: #1890ff; }
|
||||
.form-group input:disabled { background: #f5f5f5; color: #999; cursor: not-allowed; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; width: 100%; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.modal-content { background: white; border-radius: 12px; padding: 24px; width: 500px; max-height: 80vh; overflow-y: auto; }
|
||||
.modal-large { width: 700px; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { font-size: 16px; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
|
||||
.section-title { font-size: 14px; color: #1890ff; margin: 20px 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
|
||||
.cert-section { margin-bottom: 16px; padding: 12px; background: #fafafa; border-radius: 8px; }
|
||||
.cert-header { margin-bottom: 12px; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 500; }
|
||||
.checkbox-label input { width: 16px; height: 16px; }
|
||||
.cert-fields { padding-top: 8px; }
|
||||
</style>
|
||||
195
frontend/src/views/HistoryView.vue
Normal file
195
frontend/src/views/HistoryView.vue
Normal file
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="history-page">
|
||||
<div class="header-row">
|
||||
<h2>打包历史</h2>
|
||||
<div class="filters">
|
||||
<select v-model="filterBuildType" class="filter-select">
|
||||
<option value="">全部类型</option>
|
||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
||||
<option value="App_Store">App_Store</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-select">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">打包中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>App</th>
|
||||
<th>打包类型</th>
|
||||
<th>Scheme</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in filteredTasks" :key="task.id">
|
||||
<td>{{ formatTime(task.created_at) }}</td>
|
||||
<td>{{ task.app_name }}</td>
|
||||
<td>
|
||||
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
||||
{{ task.build_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.scheme_name }}</td>
|
||||
<td>
|
||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||
</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="viewLogs(task.id)">日志</button>
|
||||
<button v-if="task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button>
|
||||
<button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
|
||||
<button v-if="task.oss_url" class="action-btn" @click="downloadIpa(task.id)">下载</button>
|
||||
<button v-if="task.qr_code_path" class="action-btn" @click="showQrCode(task)">二维码</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredTasks.length">
|
||||
<td colspan="6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<div v-if="showQrModal" class="modal-overlay" @click.self="showQrModal = false">
|
||||
<div class="qr-modal">
|
||||
<div class="modal-header">
|
||||
<h3>下载二维码 - {{ qrTask?.app_name }}</h3>
|
||||
<button class="modal-close" @click="showQrModal = false">×</button>
|
||||
</div>
|
||||
<div class="qr-content">
|
||||
<img v-if="qrTask" :src="`/api/tasks/${qrTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
||||
<div class="qr-info">
|
||||
<p><strong>App:</strong> {{ qrTask?.app_name }}</p>
|
||||
<p><strong>版本:</strong> {{ qrTask?.scheme_name }}</p>
|
||||
<p><strong>类型:</strong> {{ qrTask?.build_type }}</p>
|
||||
<p><strong>时间:</strong> {{ formatTime(qrTask?.created_at) }}</p>
|
||||
</div>
|
||||
<div class="qr-actions">
|
||||
<button class="btn btn-primary" @click="downloadQrCode">保存二维码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const tasks = ref([])
|
||||
const filterBuildType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const showQrModal = ref(false)
|
||||
const qrTask = ref(null)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
// 默认隐藏失败和已取消的任务
|
||||
if (!filterStatus.value && (task.status === 'failed' || task.status === 'cancelled')) return false
|
||||
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
|
||||
if (filterStatus.value && task.status !== filterStatus.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/tasks?limit=100')
|
||||
tasks.value = await res.json()
|
||||
})
|
||||
|
||||
const viewLogs = (taskId) => {
|
||||
router.push({ path: '/build', query: { taskId } })
|
||||
}
|
||||
|
||||
const downloadDsym = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/dsym`)
|
||||
}
|
||||
|
||||
const downloadObfMaps = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
}
|
||||
|
||||
const downloadIpa = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/ipa`)
|
||||
}
|
||||
|
||||
const showQrCode = (task) => {
|
||||
qrTask.value = task
|
||||
showQrModal.value = true
|
||||
}
|
||||
|
||||
const downloadQrCode = () => {
|
||||
if (qrTask.value) {
|
||||
const link = document.createElement('a')
|
||||
link.href = `/api/tasks/${qrTask.value.id}/qrcode`
|
||||
link.download = `${qrTask.value.app_name}_二维码.png`
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString()
|
||||
}
|
||||
|
||||
const statusText = (s) => {
|
||||
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.history-page { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.header-row h2 { font-size: 18px; color: #1a1a2e; margin: 0; }
|
||||
.filters { display: flex; gap: 12px; }
|
||||
.filter-select { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; min-width: 120px; }
|
||||
.filter-select:focus { outline: none; border-color: #1890ff; }
|
||||
|
||||
.config-table { width: 100%; border-collapse: collapse; }
|
||||
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table tr:hover { background: #fafafa; }
|
||||
|
||||
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
.badge-adhoc { background: #e6f7ff; color: #1890ff; }
|
||||
.badge-appstore { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.action-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||
|
||||
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-pending { background: #f0f0f0; color: #666; }
|
||||
.status-running { background: #e6f7ff; color: #1890ff; }
|
||||
.status-completed { background: #f6ffed; color: #52c41a; }
|
||||
.status-failed { background: #fff2f0; color: #ff4d4f; }
|
||||
.status-cancelled { background: #f0f0f0; color: #999; }
|
||||
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.qr-modal { background: white; border-radius: 12px; padding: 24px; width: 400px; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { font-size: 16px; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
|
||||
.qr-content { text-align: center; }
|
||||
.qr-image { width: 200px; height: 200px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 16px; }
|
||||
.qr-info { text-align: left; padding: 16px; background: #fafafa; border-radius: 8px; margin-bottom: 16px; }
|
||||
.qr-info p { margin: 8px 0; font-size: 14px; color: #333; }
|
||||
.qr-actions { display: flex; justify-content: center; }
|
||||
.btn { padding: 10px 24px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
</style>
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
},
|
||||
})
|
||||
10
frontend/vitest.config.js
Normal file
10
frontend/vitest.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
5
pytest.ini
Normal file
5
pytest.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=7.0.0
|
||||
httpx>=0.24.0
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy>=2.0.0
|
||||
pydantic>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
57
start.sh
Executable file
57
start.sh
Executable file
@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# 一键启动本地开发环境(后端 + 前端)
|
||||
# 用法: ./start.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 加载 .env
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
PORT="${BACKEND_PORT:-8000}"
|
||||
|
||||
# 确保 .env 存在
|
||||
if [ ! -f .env ]; then
|
||||
echo "首次运行,创建 .env 配置文件..."
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
# 确保后端依赖
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "创建 Python 虚拟环境..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt -q
|
||||
|
||||
# 确保前端依赖
|
||||
if [ ! -d "frontend/node_modules" ]; then
|
||||
echo "安装前端依赖..."
|
||||
cd frontend && npm install --silent && cd ..
|
||||
fi
|
||||
|
||||
echo "=== iOS 自动打包服务(开发模式)==="
|
||||
echo " 前端: http://localhost:3000"
|
||||
echo " 后端: http://localhost:$PORT"
|
||||
echo " 按 Ctrl+C 停止所有服务"
|
||||
echo ""
|
||||
|
||||
# 清理子进程
|
||||
cleanup() {
|
||||
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
# 启动后端
|
||||
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$PORT" --reload &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 启动前端
|
||||
cd frontend && npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
wait
|
||||
59
tests/conftest.py
Normal file
59
tests/conftest.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""测试公共配置"""
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# 使用临时数据库,避免污染开发数据库
|
||||
_test_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_test_db.close()
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_test_db.name}"
|
||||
|
||||
from backend.main import app
|
||||
from backend.database import init_db, engine, Base
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""每个测试前重建数据库"""
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI 测试客户端"""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path, monkeypatch):
|
||||
"""使用临时 config.json"""
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({
|
||||
"apps": {
|
||||
"1": {
|
||||
"name": "测试App",
|
||||
"server": "测试环境",
|
||||
"AppGuid": "test-guid",
|
||||
"API": "https://test.api.com",
|
||||
"certificates": {
|
||||
"Ad_Hoc": {"name": "com.test.app", "cer": "test-cert", "pro": "test-pro", "theme": "test"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemes": {
|
||||
"1": {"name": "testScheme", "ossFloder": "test"}
|
||||
},
|
||||
"servers": {
|
||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
||||
},
|
||||
"branches": ["main", "dev"],
|
||||
}))
|
||||
monkeypatch.setattr("backend.routers.config.CONFIG_JSON_PATH", config_path)
|
||||
return config_path
|
||||
21
tests/test_api_apps.py
Normal file
21
tests/test_api_apps.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""打包选择接口测试(只读)"""
|
||||
|
||||
|
||||
def test_get_apps(client, tmp_config):
|
||||
resp = client.get("/api/apps")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_get_schemes(client, tmp_config):
|
||||
resp = client.get("/api/schemes")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_get_branches(client, tmp_config):
|
||||
resp = client.get("/api/branches")
|
||||
assert resp.status_code == 200
|
||||
branches = resp.json()
|
||||
assert "main" in branches
|
||||
assert "dev" in branches
|
||||
19
tests/test_api_auth.py
Normal file
19
tests/test_api_auth.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""认证接口测试"""
|
||||
|
||||
|
||||
def test_login_success(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["token"] == "admin-token"
|
||||
assert data["is_admin"] is True
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_login_wrong_username(client):
|
||||
resp = client.post("/api/auth/login", json={"username": "nobody", "password": "admin123"})
|
||||
assert resp.status_code == 401
|
||||
146
tests/test_api_config.py
Normal file
146
tests/test_api_config.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""配置管理接口测试"""
|
||||
import json
|
||||
|
||||
|
||||
def test_get_full_config(client, tmp_config):
|
||||
resp = client.get("/api/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "apps" in data
|
||||
assert "schemes" in data
|
||||
assert "branches" in data
|
||||
|
||||
|
||||
# ---- Apps ----
|
||||
|
||||
def test_get_apps(client, tmp_config):
|
||||
resp = client.get("/api/config/apps")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_create_app(client, tmp_config):
|
||||
resp = client.post("/api/config/apps", json={"name": "新App", "AppGuid": "new-guid"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "2"
|
||||
|
||||
# 验证已创建
|
||||
apps = client.get("/api/config/apps").json()
|
||||
assert "2" in apps
|
||||
assert apps["2"]["name"] == "新App"
|
||||
|
||||
|
||||
def test_update_app(client, tmp_config):
|
||||
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/apps").json()["1"]["name"] == "改名App"
|
||||
|
||||
|
||||
def test_delete_app(client, tmp_config):
|
||||
resp = client.delete("/api/config/apps/1")
|
||||
assert resp.status_code == 200
|
||||
assert "1" not in client.get("/api/config/apps").json()
|
||||
|
||||
|
||||
def test_update_nonexistent_app(client, tmp_config):
|
||||
resp = client.put("/api/config/apps/999", json={"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- Schemes ----
|
||||
|
||||
def test_get_schemes(client, tmp_config):
|
||||
resp = client.get("/api/config/schemes")
|
||||
assert resp.status_code == 200
|
||||
assert "1" in resp.json()
|
||||
|
||||
|
||||
def test_create_scheme(client, tmp_config):
|
||||
resp = client.post("/api/config/schemes", json={"name": "newScheme", "ossFloder": "new"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "2"
|
||||
|
||||
|
||||
def test_delete_scheme(client, tmp_config):
|
||||
resp = client.delete("/api/config/schemes/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---- Branches ----
|
||||
|
||||
def test_get_branches(client, tmp_config):
|
||||
resp = client.get("/api/config/branches")
|
||||
assert resp.status_code == 200
|
||||
branches = resp.json()
|
||||
assert "main" in branches
|
||||
assert "dev" in branches
|
||||
|
||||
|
||||
def test_add_branch(client, tmp_config):
|
||||
resp = client.post("/api/config/branches", json={"name": "release/1.0"})
|
||||
assert resp.status_code == 200
|
||||
branches = client.get("/api/config/branches").json()
|
||||
assert "release/1.0" in branches
|
||||
|
||||
|
||||
def test_add_duplicate_branch(client, tmp_config):
|
||||
resp = client.post("/api/config/branches", json={"name": "main"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_add_empty_branch(client, tmp_config):
|
||||
resp = client.post("/api/config/branches", json={"name": ""})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_delete_branch(client, tmp_config):
|
||||
resp = client.delete("/api/config/branches/dev")
|
||||
assert resp.status_code == 200
|
||||
branches = client.get("/api/config/branches").json()
|
||||
assert "dev" not in branches
|
||||
|
||||
|
||||
def test_delete_nonexistent_branch(client, tmp_config):
|
||||
resp = client.delete("/api/config/branches/notexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- Servers ----
|
||||
|
||||
def test_get_servers(client, tmp_config):
|
||||
resp = client.get("/api/config/servers")
|
||||
assert resp.status_code == 200
|
||||
assert "测试环境" in resp.json()
|
||||
|
||||
|
||||
def test_create_server(client, tmp_config):
|
||||
resp = client.post("/api/config/servers", json={
|
||||
"name": "新环境",
|
||||
"api": "https://new.api.com",
|
||||
"assDom": "applinks:new.com",
|
||||
"universalLink": "https://new.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "新环境" in client.get("/api/config/servers").json()
|
||||
|
||||
|
||||
def test_delete_server_in_use(client, tmp_config):
|
||||
# 测试环境被 App 1 使用,应该删除失败
|
||||
resp = client.delete("/api/config/servers/测试环境")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---- Build Settings ----
|
||||
|
||||
def test_get_build_settings(client, tmp_config):
|
||||
resp = client.get("/api/config/build")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "max_concurrent_builds" in data
|
||||
assert "build_dir_retention_hours" in data
|
||||
|
||||
|
||||
def test_update_build_settings(client, tmp_config):
|
||||
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
|
||||
8
tests/test_api_health.py
Normal file
8
tests/test_api_health.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""健康检查测试"""
|
||||
|
||||
|
||||
def test_health(client):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
114
tests/test_api_tasks.py
Normal file
114
tests/test_api_tasks.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""任务接口测试"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
|
||||
def test_list_tasks_empty(client, tmp_config):
|
||||
resp = client.get("/api/tasks")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
"obfuscation": True,
|
||||
"branch": "main",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "pending"
|
||||
assert data["app_name"] == "测试App"
|
||||
assert data["scheme_name"] == "testScheme"
|
||||
assert data["branch"] == "main"
|
||||
assert data["build_type"] == "Ad_Hoc"
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_default_branch(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["branch"] == "main"
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_invalid_app(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "999",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_invalid_scheme(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "999",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
tasks = client.get("/api/tasks").json()
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["app_name"] == "测试App"
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_get_task_detail(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
create_resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
task_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.get(f"/api/tasks/{task_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == task_id
|
||||
|
||||
|
||||
def test_get_nonexistent_task(client, tmp_config):
|
||||
resp = client.get("/api/tasks/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_cancel_task(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
mock_queue.cancel = lambda x: None
|
||||
|
||||
create_resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
task_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.delete(f"/api/tasks/{task_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 验证状态已更新
|
||||
task = client.get(f"/api/tasks/{task_id}").json()
|
||||
assert task["status"] == "cancelled"
|
||||
118
tests/test_build_queue.py
Normal file
118
tests/test_build_queue.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""打包队列测试"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from backend.services.build_queue import BuildQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue():
|
||||
return BuildQueue(max_concurrent=2)
|
||||
|
||||
|
||||
def test_initial_state(queue):
|
||||
"""初始状态"""
|
||||
assert queue.queue_size == 0
|
||||
assert queue.running_count == 0
|
||||
|
||||
|
||||
def test_submit_starts_workers(queue):
|
||||
"""提交任务后自动启动 worker"""
|
||||
|
||||
async def dummy_build(task_id):
|
||||
pass
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", dummy_build)
|
||||
# 等待任务完成
|
||||
await asyncio.sleep(0.1)
|
||||
assert queue._started
|
||||
assert len(queue._workers) == 2
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run())
|
||||
|
||||
|
||||
def test_task_executes(queue):
|
||||
"""任务被执行"""
|
||||
executed = []
|
||||
|
||||
async def mock_build(task_id):
|
||||
executed.append(task_id)
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", mock_build)
|
||||
await asyncio.sleep(0.2)
|
||||
return executed
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert "t1" in result
|
||||
|
||||
|
||||
def test_concurrent_limit(queue):
|
||||
"""并发数不超过限制"""
|
||||
running = []
|
||||
max_concurrent = 0
|
||||
|
||||
async def slow_build(task_id):
|
||||
nonlocal max_concurrent
|
||||
running.append(task_id)
|
||||
max_concurrent = max(max_concurrent, len(running))
|
||||
await asyncio.sleep(0.3)
|
||||
running.remove(task_id)
|
||||
|
||||
async def run():
|
||||
tasks = []
|
||||
for i in range(5):
|
||||
tasks.append(queue.submit(f"t{i}", slow_build))
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.sleep(1) # 等所有任务完成
|
||||
return max_concurrent
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert result <= 2 # max_concurrent = 2
|
||||
|
||||
|
||||
def test_cancel_running_task(queue):
|
||||
"""取消运行中的任务"""
|
||||
cancelled = False
|
||||
|
||||
async def long_build(task_id):
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
raise
|
||||
|
||||
async def run():
|
||||
await queue.submit("t1", long_build)
|
||||
await asyncio.sleep(0.1) # 等任务开始
|
||||
queue.cancel("t1")
|
||||
await asyncio.sleep(0.2) # 等取消生效
|
||||
return cancelled
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_cancel_nonexistent_task(queue):
|
||||
"""取消不存在的任务(不应报错)"""
|
||||
queue.cancel("nonexistent")
|
||||
|
||||
|
||||
def test_multiple_tasks(queue):
|
||||
"""多个任务顺序执行"""
|
||||
completed = []
|
||||
|
||||
async def mock_build(task_id):
|
||||
await asyncio.sleep(0.05)
|
||||
completed.append(task_id)
|
||||
|
||||
async def run():
|
||||
for i in range(4):
|
||||
await queue.submit(f"t{i}", mock_build)
|
||||
await asyncio.sleep(1)
|
||||
return completed
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(run())
|
||||
assert len(result) == 4
|
||||
assert set(result) == {"t0", "t1", "t2", "t3"}
|
||||
292
tests/test_build_service.py
Normal file
292
tests/test_build_service.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""打包服务核心逻辑测试"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services.build_service import (
|
||||
update_source,
|
||||
copy_source_code,
|
||||
generate_config,
|
||||
_cleanup_old_builds,
|
||||
)
|
||||
from backend.services.log_streamer import LogStreamer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_streamer():
|
||||
return LogStreamer()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dirs(tmp_path):
|
||||
"""创建临时源码目录和打包目录"""
|
||||
source_dir = tmp_path / "source"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "readoor").mkdir()
|
||||
(source_dir / "readoor" / "AppDelegate.swift").write_text("// app")
|
||||
(source_dir / "readoor.xcodeproj").mkdir()
|
||||
(source_dir / "Pods").mkdir()
|
||||
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
|
||||
(source_dir / "readoor.xcworkspace").mkdir()
|
||||
(source_dir / "AutoPacking").mkdir()
|
||||
(source_dir / "readoorTests").mkdir()
|
||||
(source_dir / "podfile").write_text("pod 'AFNetworking'")
|
||||
|
||||
build_dir = tmp_path / "build"
|
||||
return source_dir, build_dir
|
||||
|
||||
|
||||
def _make_mock_process(returncode=0, output=b"ok\n"):
|
||||
"""创建 mock 进程,支持 async for stdout"""
|
||||
proc = AsyncMock()
|
||||
proc.returncode = returncode
|
||||
proc.wait = AsyncMock(return_value=returncode)
|
||||
|
||||
async def iter_stdout():
|
||||
yield output
|
||||
|
||||
proc.stdout = iter_stdout()
|
||||
return proc
|
||||
|
||||
|
||||
# ---- update_source ----
|
||||
|
||||
async def test_update_source_clone(tmp_path, log_streamer):
|
||||
"""目录不存在时执行 clone"""
|
||||
source_dir = tmp_path / "branches" / "main"
|
||||
source_dir.parent.mkdir()
|
||||
|
||||
with patch("backend.services.build_service.GIT_REMOTE_URL", "git@github.com:test/repo.git"):
|
||||
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
||||
await update_source("t1", source_dir, "main")
|
||||
mock_exec.assert_called_once()
|
||||
args = mock_exec.call_args[0]
|
||||
assert "git" in args
|
||||
assert "clone" in args
|
||||
|
||||
|
||||
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
||||
"""目录不存在且未配置远程仓库时抛异常"""
|
||||
source_dir = tmp_path / "branches" / "main"
|
||||
|
||||
with patch("backend.services.build_service.GIT_REMOTE_URL", ""):
|
||||
with pytest.raises(Exception, match="GIT_REMOTE_URL"):
|
||||
await update_source("t1", source_dir, "main")
|
||||
|
||||
|
||||
async def test_update_source_pull_existing(tmp_path, log_streamer):
|
||||
"""目录已存在时执行 fetch + checkout + pull"""
|
||||
source_dir = tmp_path / "branches" / "dev"
|
||||
source_dir.parent.mkdir(parents=True)
|
||||
source_dir.mkdir()
|
||||
(source_dir / ".git").mkdir()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_exec(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _make_mock_process()
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
assert call_count == 3 # fetch, checkout, pull
|
||||
|
||||
|
||||
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||
"""fetch 失败时抛异常"""
|
||||
source_dir = tmp_path / "branches" / "dev"
|
||||
source_dir.parent.mkdir(parents=True)
|
||||
source_dir.mkdir()
|
||||
|
||||
async def mock_exec(*args, **kwargs):
|
||||
return _make_mock_process(returncode=1, output=b"error\n")
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
with pytest.raises(Exception, match="git fetch 失败"):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
|
||||
# ---- copy_source_code ----
|
||||
|
||||
async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||
"""正常拷贝源码"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
|
||||
task = MagicMock()
|
||||
task.branch = "main"
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||
result = await copy_source_code("t1", task, source_dir)
|
||||
|
||||
assert result.exists()
|
||||
assert (result / "readoor" / "AppDelegate.swift").exists()
|
||||
assert (result / "Pods").exists()
|
||||
assert (result / "Podfile.lock").exists()
|
||||
assert (result / "readoor.xcworkspace").exists()
|
||||
|
||||
|
||||
async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer):
|
||||
"""拷贝时排除 .git 目录"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
(source_dir / "readoor" / ".git").mkdir()
|
||||
|
||||
task = MagicMock()
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||
result = await copy_source_code("t1", task, source_dir)
|
||||
|
||||
assert not (result / "readoor" / ".git").exists()
|
||||
|
||||
|
||||
async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
|
||||
"""打包目录已存在时覆盖"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
old_dir = build_dir_parent / "build_readoor_t1"
|
||||
old_dir.mkdir(parents=True)
|
||||
(old_dir / "old_file.txt").write_text("old")
|
||||
|
||||
task = MagicMock()
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||
result = await copy_source_code("t1", task, source_dir)
|
||||
|
||||
assert not (result / "old_file.txt").exists()
|
||||
assert (result / "readoor").exists()
|
||||
|
||||
|
||||
# ---- generate_config ----
|
||||
|
||||
async def test_generate_config(tmp_path, log_streamer):
|
||||
"""生成打包配置"""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
|
||||
task = MagicMock()
|
||||
task.app_id = "1"
|
||||
task.scheme_id = "1"
|
||||
task.build_type = "Ad_Hoc"
|
||||
task.obfuscation = True
|
||||
|
||||
mock_config = {
|
||||
"apps": {
|
||||
"1": {
|
||||
"name": "测试App",
|
||||
"server": "测试环境",
|
||||
"AppGuid": "guid-123",
|
||||
"API": "https://test.api.com",
|
||||
"certificates": {
|
||||
"Ad_Hoc": {"name": "com.test.app", "cer": "cert", "pro": "pro", "theme": "t"}
|
||||
},
|
||||
}
|
||||
},
|
||||
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
|
||||
}
|
||||
|
||||
with patch("backend.services.build_service.AUTOPACKING_DIR", tmp_path / "AutoPacking"):
|
||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
||||
result = await generate_config("t1", task, build_dir)
|
||||
|
||||
assert result["APPID"] == "guid-123"
|
||||
assert result["SCHEME"] == "testScheme"
|
||||
assert result["BUILD_TYPE"] == "Ad_Hoc"
|
||||
assert result["BUNDLE_ID"] == "com.test.app"
|
||||
assert result["CERTIFICATE"] == "cert"
|
||||
|
||||
config_file = build_dir / "config_output.json"
|
||||
assert config_file.exists()
|
||||
saved = json.loads(config_file.read_text())
|
||||
assert saved["APPID"] == "guid-123"
|
||||
|
||||
|
||||
async def test_generate_config_reads_version(tmp_path, log_streamer):
|
||||
"""从 start_build_app.py 读取版本号"""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
|
||||
autopacking = tmp_path / "AutoPacking"
|
||||
autopacking.mkdir()
|
||||
(autopacking / "start_build_app.py").write_text(
|
||||
'App_Ver = "3.0.1"\nBuild_Ver = "3.0.1.0"\n'
|
||||
)
|
||||
|
||||
task = MagicMock()
|
||||
task.app_id = "1"
|
||||
task.scheme_id = "1"
|
||||
task.build_type = "Ad_Hoc"
|
||||
task.obfuscation = False
|
||||
|
||||
mock_config = {
|
||||
"apps": {"1": {"name": "App", "certificates": {}}},
|
||||
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
|
||||
}
|
||||
|
||||
with patch("backend.services.build_service.AUTOPACKING_DIR", autopacking):
|
||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
||||
result = await generate_config("t1", task, build_dir)
|
||||
|
||||
assert result["VERSION"] == "3.0.1"
|
||||
assert result["BUILD_VERSION"] == "3.0.1.0"
|
||||
|
||||
|
||||
# ---- _cleanup_old_builds ----
|
||||
|
||||
def test_cleanup_old_builds(tmp_path):
|
||||
"""清理过期打包目录"""
|
||||
from backend.database import SessionLocal, init_db
|
||||
from backend.models import BuildConfig
|
||||
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
if config:
|
||||
config.value = "1"
|
||||
else:
|
||||
db.add(BuildConfig(key="build_dir_retention_hours", value="1"))
|
||||
db.commit()
|
||||
|
||||
old_dir = tmp_path / "build_readoor_old"
|
||||
old_dir.mkdir()
|
||||
# 设置修改时间为 24 小时前(确保超过 1 小时保留期)
|
||||
old_time = time.time() - 86400
|
||||
os.utime(old_dir, (old_time, old_time))
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path):
|
||||
_cleanup_old_builds(db)
|
||||
|
||||
assert not old_dir.exists()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cleanup_keeps_recent(tmp_path):
|
||||
"""保留未过期的打包目录"""
|
||||
from backend.database import SessionLocal, init_db
|
||||
from backend.models import BuildConfig
|
||||
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
|
||||
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
|
||||
if config:
|
||||
config.value = "24"
|
||||
else:
|
||||
db.add(BuildConfig(key="build_dir_retention_hours", value="24"))
|
||||
db.commit()
|
||||
|
||||
recent_dir = tmp_path / "build_readoor_recent"
|
||||
recent_dir.mkdir()
|
||||
|
||||
with patch("backend.services.build_service.BUILD_BASE_DIR", tmp_path):
|
||||
_cleanup_old_builds(db)
|
||||
|
||||
assert recent_dir.exists()
|
||||
db.close()
|
||||
151
tests/test_downloads.py
Normal file
151
tests/test_downloads.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""文件下载接口测试"""
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import Task
|
||||
|
||||
|
||||
def _create_task_with_files(client, tmp_config, **extra_fields):
|
||||
"""创建一个带文件路径的任务"""
|
||||
with patch("backend.services.build_queue.build_queue") as mock_queue:
|
||||
mock_queue.submit = AsyncMock()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
task_id = resp.json()["id"]
|
||||
|
||||
# 直接更新数据库中的文件路径
|
||||
db = SessionLocal()
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
for k, v in extra_fields.items():
|
||||
setattr(task, k, v)
|
||||
db.commit()
|
||||
db.close()
|
||||
return task_id
|
||||
|
||||
|
||||
# ---- dSYM 下载 ----
|
||||
|
||||
def test_download_dsym_not_exist(client, tmp_config):
|
||||
"""dSYM 路径未设置"""
|
||||
task_id = _create_task_with_files(client, tmp_config)
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_dsym_file_missing(client, tmp_config):
|
||||
"""dSYM 路径设置了但文件不存在"""
|
||||
task_id = _create_task_with_files(client, tmp_config, dsym_path="/tmp/nonexistent.dSYM")
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 404
|
||||
assert "已被清理" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_download_dsym_success(client, tmp_config):
|
||||
"""dSYM 文件正常下载"""
|
||||
# 创建临时文件
|
||||
fd, path = tempfile.mkstemp(suffix=".dSYM")
|
||||
os.write(fd, b"fake dsym content")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(client, tmp_config, dsym_path=path)
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"fake dsym content"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_download_dsym_nonexistent_task(client, tmp_config):
|
||||
resp = client.get("/api/tasks/nonexistent/dsym")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- 混淆映射表下载 ----
|
||||
|
||||
def test_download_obfuscation_maps_not_exist(client, tmp_config):
|
||||
"""混淆映射表路径未设置"""
|
||||
task_id = _create_task_with_files(client, tmp_config)
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_obfuscation_maps_file_missing(client, tmp_config):
|
||||
"""路径设置了但文件不存在"""
|
||||
task_id = _create_task_with_files(client, tmp_config, obfuscation_maps_path="/tmp/nonexistent")
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_obfuscation_maps_single_file(client, tmp_config):
|
||||
"""单文件下载"""
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
os.write(fd, b'{"mappings": []}')
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(client, tmp_config, obfuscation_maps_path=path)
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b'{"mappings": []}'
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_download_obfuscation_maps_directory(client, tmp_config):
|
||||
"""目录打包成 zip 下载"""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
with open(os.path.join(tmpdir, "map.json"), "w") as f:
|
||||
f.write("{}")
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(
|
||||
client, tmp_config,
|
||||
obfuscation_maps_path=tmpdir,
|
||||
app_name="测试App",
|
||||
)
|
||||
resp = client.get(f"/api/tasks/{task_id}/obfuscation-maps")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/zip"
|
||||
# filename is URL-encoded, check the raw header
|
||||
disposition = resp.headers["content-disposition"]
|
||||
assert ".zip" in disposition
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
# ---- 二维码下载 ----
|
||||
|
||||
def test_download_qrcode_not_exist(client, tmp_config):
|
||||
"""二维码路径未设置"""
|
||||
task_id = _create_task_with_files(client, tmp_config)
|
||||
resp = client.get(f"/api/tasks/{task_id}/qrcode")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_qrcode_file_missing(client, tmp_config):
|
||||
"""路径设置了但文件不存在"""
|
||||
task_id = _create_task_with_files(client, tmp_config, qr_code_path="/tmp/nonexistent.png")
|
||||
resp = client.get(f"/api/tasks/{task_id}/qrcode")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_qrcode_success(client, tmp_config):
|
||||
"""二维码正常下载"""
|
||||
fd, path = tempfile.mkstemp(suffix=".png")
|
||||
os.write(fd, b"\x89PNG fake")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
task_id = _create_task_with_files(client, tmp_config, qr_code_path=path)
|
||||
resp = client.get(f"/api/tasks/{task_id}/qrcode")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
160
tests/test_edge_cases.py
Normal file
160
tests/test_edge_cases.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""边界情况测试"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def test_invalid_json_body(client, tmp_config):
|
||||
"""无效 JSON 请求体"""
|
||||
resp = client.post(
|
||||
"/api/tasks",
|
||||
content="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_missing_required_fields(client, tmp_config):
|
||||
"""缺少必填字段"""
|
||||
resp = client.post("/api/tasks", json={"app_id": "1"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_empty_app_id(client, tmp_config):
|
||||
"""空 app_id"""
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "",
|
||||
"build_type": "Ad_Hoc",
|
||||
"scheme_id": "1",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_server_name_with_special_chars(client, tmp_config):
|
||||
"""服务器名称包含特殊字符"""
|
||||
resp = client.post("/api/config/servers", json={
|
||||
"name": "环境/测试",
|
||||
"api": "https://test.com",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_branch_name_with_slash(client, tmp_config):
|
||||
"""分支名包含斜杠"""
|
||||
resp = client.post("/api/config/branches", json={"name": "feature/my-branch"})
|
||||
assert resp.status_code == 200
|
||||
assert "feature/my-branch" in client.get("/api/config/branches").json()
|
||||
|
||||
|
||||
def test_branch_name_with_space(client, tmp_config):
|
||||
"""分支名包含空格(应被 trim)"""
|
||||
resp = client.post("/api/config/branches", json={"name": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_config_backup_on_save(client, tmp_config):
|
||||
"""保存配置时自动备份"""
|
||||
# 先写一次产生文件
|
||||
client.put("/api/config", json={"apps": {}, "schemes": {}, "branches": ["main"]})
|
||||
|
||||
# 再写一次,应该产生 .bak 文件
|
||||
client.put("/api/config", json={"apps": {"1": {"name": "test"}}, "schemes": {}, "branches": ["main"]})
|
||||
|
||||
backup = tmp_config.with_suffix(".json.bak")
|
||||
assert backup.exists()
|
||||
|
||||
|
||||
def test_sequential_config_writes(client, tmp_config):
|
||||
"""顺序多次写入配置"""
|
||||
for i in range(5):
|
||||
resp = client.post("/api/config/branches", json={"name": f"branch-{i}"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
branches = client.get("/api/config/branches").json()
|
||||
for i in range(5):
|
||||
assert f"branch-{i}" in branches
|
||||
|
||||
|
||||
def test_task_status_filter(client, tmp_config):
|
||||
"""按状态过滤任务"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
with patch("backend.services.build_queue.build_queue") as mock_queue:
|
||||
mock_queue.submit = AsyncMock()
|
||||
client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1",
|
||||
})
|
||||
|
||||
# 过滤 pending
|
||||
resp = client.get("/api/tasks?status=pending")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
# 过滤 running(应该没有)
|
||||
resp = client.get("/api/tasks?status=running")
|
||||
assert len(resp.json()) == 0
|
||||
|
||||
|
||||
def test_task_pagination(client, tmp_config):
|
||||
"""任务分页"""
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
with patch("backend.services.build_queue.build_queue") as mock_queue:
|
||||
mock_queue.submit = AsyncMock()
|
||||
for _ in range(5):
|
||||
client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1",
|
||||
})
|
||||
|
||||
resp = client.get("/api/tasks?limit=2&offset=0")
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
resp = client.get("/api/tasks?limit=2&offset=2")
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
def test_delete_server_not_in_use(client, tmp_config):
|
||||
"""删除未被使用的服务器"""
|
||||
# 先添加一个不被使用的服务器
|
||||
client.post("/api/config/servers", json={"name": "临时环境", "api": "https://tmp.com"})
|
||||
resp = client.delete("/api/config/servers/临时环境")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_update_full_config_replaces_all(client, tmp_config):
|
||||
"""整体替换配置"""
|
||||
new_config = {
|
||||
"apps": {"99": {"name": "新App"}},
|
||||
"schemes": {},
|
||||
"servers": {},
|
||||
"branches": ["release"],
|
||||
}
|
||||
resp = client.put("/api/config", json=new_config)
|
||||
assert resp.status_code == 200
|
||||
|
||||
config = client.get("/api/config").json()
|
||||
assert "99" in config["apps"]
|
||||
assert config["branches"] == ["release"]
|
||||
|
||||
|
||||
def test_scheme_id_auto_increment(client, tmp_config):
|
||||
"""Scheme ID 自动递增"""
|
||||
client.post("/api/config/schemes", json={"name": "sch2", "ossFloder": "f2"})
|
||||
client.post("/api/config/schemes", json={"name": "sch3", "ossFloder": "f3"})
|
||||
schemes = client.get("/api/config/schemes").json()
|
||||
assert "2" in schemes
|
||||
assert "3" in schemes
|
||||
|
||||
|
||||
def test_build_settings_validation(client, tmp_config):
|
||||
"""打包设置更新"""
|
||||
# 更新为有效值
|
||||
resp = client.put("/api/config/build", json={"max_concurrent_builds": 3})
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 3
|
||||
|
||||
# 更新部分字段
|
||||
resp = client.put("/api/config/build", json={"build_dir_retention_hours": 48})
|
||||
assert resp.status_code == 200
|
||||
settings = client.get("/api/config/build").json()
|
||||
assert settings["build_dir_retention_hours"] == 48
|
||||
assert settings["max_concurrent_builds"] == 3 # 未修改的保持不变
|
||||
131
tests/test_websocket.py
Normal file
131
tests/test_websocket.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""WebSocket 日志流测试"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from backend.services.log_streamer import LogStreamer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def streamer():
|
||||
return LogStreamer()
|
||||
|
||||
|
||||
async def test_emit_and_subscribe(streamer):
|
||||
"""测试发送和接收日志"""
|
||||
streamer.create_queue("t1")
|
||||
await streamer.emit("t1", "hello", level="info")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t1"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0]["message"] == "hello"
|
||||
assert received[0]["level"] == "info"
|
||||
|
||||
|
||||
async def test_emit_step(streamer):
|
||||
"""测试步骤标记"""
|
||||
streamer.create_queue("t2")
|
||||
await streamer.emit_step("t2", "构建中")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t2"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert received[0]["message"] == "[构建中]"
|
||||
assert received[0]["level"] == "step"
|
||||
|
||||
|
||||
async def test_emit_error(streamer):
|
||||
"""测试错误消息"""
|
||||
streamer.create_queue("t3")
|
||||
await streamer.emit_error("t3", "出错了")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t3"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert received[0]["level"] == "error"
|
||||
assert received[0]["message"] == "出错了"
|
||||
|
||||
|
||||
async def test_emit_warning(streamer):
|
||||
"""测试警告消息"""
|
||||
streamer.create_queue("t4")
|
||||
await streamer.emit_warning("t4", "警告")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t4"):
|
||||
received.append(msg)
|
||||
break
|
||||
|
||||
assert received[0]["level"] == "warn"
|
||||
|
||||
|
||||
async def test_emit_to_nonexistent_queue(streamer):
|
||||
"""测试向不存在的队列发送消息(不应报错)"""
|
||||
await streamer.emit("nonexistent", "msg")
|
||||
|
||||
|
||||
async def test_complete_signal(streamer):
|
||||
"""测试完成信号终止订阅"""
|
||||
streamer.create_queue("t5")
|
||||
await streamer.emit("t5", "msg1")
|
||||
streamer.complete("t5")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t5"):
|
||||
received.append(msg)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0]["message"] == "msg1"
|
||||
|
||||
|
||||
async def test_multiple_messages(streamer):
|
||||
"""测试多条消息顺序"""
|
||||
streamer.create_queue("t6")
|
||||
for i in range(5):
|
||||
await streamer.emit("t6", f"msg{i}")
|
||||
streamer.complete("t6")
|
||||
|
||||
received = []
|
||||
async for msg in streamer.subscribe("t6"):
|
||||
received.append(msg)
|
||||
|
||||
assert len(received) == 5
|
||||
for i, msg in enumerate(received):
|
||||
assert msg["message"] == f"msg{i}"
|
||||
|
||||
|
||||
async def test_queue_full_drops_oldest(streamer):
|
||||
"""测试队列满时丢弃最旧消息"""
|
||||
q = streamer.create_queue("t7")
|
||||
for i in range(q.maxsize):
|
||||
await streamer.emit("t7", f"old{i}")
|
||||
await streamer.emit("t7", "new")
|
||||
|
||||
messages = []
|
||||
while not q.empty():
|
||||
messages.append(await q.get())
|
||||
assert messages[-1]["message"] == "new"
|
||||
|
||||
|
||||
async def test_cleanup(streamer):
|
||||
"""测试清理队列"""
|
||||
streamer.create_queue("t8")
|
||||
await streamer.emit("t8", "msg")
|
||||
streamer.cleanup("t8")
|
||||
assert "t8" not in streamer._queues
|
||||
# 清理后再发送不应报错
|
||||
await streamer.emit("t8", "msg2")
|
||||
|
||||
|
||||
async def test_subscribe_without_queue(streamer):
|
||||
"""测试订阅不存在的队列(应立即结束)"""
|
||||
collected = []
|
||||
async for msg in streamer.subscribe("nonexistent"):
|
||||
collected.append(msg)
|
||||
assert collected == []
|
||||
Loading…
Reference in New Issue
Block a user