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:
shen 2026-06-06 17:42:27 +08:00
commit 6f4f625c56
51 changed files with 9968 additions and 0 deletions

43
.env.example Normal file
View 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
View 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
View 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
View File

67
backend/config.py Normal file
View 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
View 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
View 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
View 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)

View File

27
backend/routers/apps.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

View 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()

View 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

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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
View 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>

View 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('管理')
})
})

View 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) // 只显示未完成的任务pendingcompleted 已过滤
})
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('已取消')
})
})

View 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('设置已保存')
})
})

View 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()
// 只显示 App1completed和 App3pendingApp2failed被隐藏
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('下载二维码')
})
})

View 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
View 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')

View 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

View 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>

View 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">&times;</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">&times;</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">&times;</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>

View 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">&times;</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
View 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
View 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
View File

@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
asyncio_mode = auto

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
-r requirements.txt
pytest>=7.0.0
httpx>=0.24.0

5
requirements.txt Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 == []