Compare commits
18
Commits
d1f070b251
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
624f534b0f | ||
|
|
13366ab3b4 | ||
|
|
44405e589d | ||
|
|
de33b3d4b2 | ||
|
|
8747792a4b | ||
|
|
89fb633b00 | ||
|
|
51d7374f2d | ||
|
|
9b77441ec7 | ||
|
|
8c0bce45a4 | ||
|
|
cf6cc1d21a | ||
|
|
cede7978e4 | ||
|
|
f0c6a2d9b6 | ||
|
|
3e3e4123d0 | ||
|
|
c209ae423c | ||
|
|
2ad5f208ed | ||
|
|
2661435273 | ||
|
|
7fd3845447 | ||
|
|
a48909f3bc |
+22
-6
@@ -2,17 +2,21 @@
|
|||||||
# 复制此文件为 .env 并修改为实际值
|
# 复制此文件为 .env 并修改为实际值
|
||||||
|
|
||||||
# ---- 目录配置 ----
|
# ---- 目录配置 ----
|
||||||
# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev)
|
# 源码根目录;未配置远程仓库时,每个分支一个子目录
|
||||||
GIT_SOURCE_BASE=./ReadoorBranches
|
GIT_SOURCE_BASE=./ReadoorBranches
|
||||||
|
|
||||||
|
# 配置远程仓库时使用的唯一共享源码工作目录;服务会串行切分支、清理并复制快照
|
||||||
|
GIT_SOURCE_DIR=./ReadoorBranches/workspace
|
||||||
|
|
||||||
# 打包输出基础目录(每次打包会在此目录下创建子目录)
|
# 打包输出基础目录(每次打包会在此目录下创建子目录)
|
||||||
BUILD_BASE_DIR=./build
|
BUILD_BASE_DIR=./build
|
||||||
|
|
||||||
# ---- 分支源码管理 ----
|
# ---- 分支源码管理 ----
|
||||||
# Git 远程仓库地址,分支目录不存在时自动 clone
|
# Git 远程仓库地址;配置后所有分支共用 GIT_SOURCE_DIR
|
||||||
GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git
|
GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git
|
||||||
GIT_USERNAME=shenlei
|
# 私有仓库:使用只读 Personal Access Token,不要使用账号登录密码。
|
||||||
GIT_PASSWORD='&s9U5fbreA'
|
GIT_USERNAME=your-git-username
|
||||||
|
GIT_PASSWORD=your-read-repository-token
|
||||||
|
|
||||||
# ---- 服务端口 ----
|
# ---- 服务端口 ----
|
||||||
BACKEND_PORT=5002
|
BACKEND_PORT=5002
|
||||||
@@ -20,11 +24,23 @@ FRONTEND_PORT=5999
|
|||||||
|
|
||||||
# ---- 管理员账号 ----
|
# ---- 管理员账号 ----
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=change-this-before-starting
|
||||||
|
|
||||||
# ---- JWT 认证 ----
|
# ---- JWT 认证 ----
|
||||||
# JWT 签名密钥(生产环境务必修改为随机字符串)
|
# JWT 签名密钥(生产环境务必修改为随机字符串)
|
||||||
JWT_SECRET=ios-build-server-secret-key-change-in-production
|
JWT_SECRET=replace-with-a-random-string-of-at-least-32-characters
|
||||||
|
|
||||||
|
# ---- 公网部署 ----
|
||||||
|
# production 会拒绝默认密钥、通配 CORS 和未配置的可信主机。
|
||||||
|
APP_ENV=production
|
||||||
|
# Uvicorn 只监听本机,由 Nginx/Caddy 提供 HTTPS 和公网访问。
|
||||||
|
BIND_HOST=127.0.0.1
|
||||||
|
CORS_ALLOWED_ORIGINS=https://build.example.com
|
||||||
|
TRUSTED_HOSTS=build.example.com
|
||||||
|
# 登录失败 5 次后锁定 15 分钟。
|
||||||
|
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||||
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
|
LOGIN_RATE_LIMIT_LOCKOUT_SECONDS=900
|
||||||
|
|
||||||
# ---- 并发与清理 ----
|
# ---- 并发与清理 ----
|
||||||
# 最大并行打包数
|
# 最大并行打包数
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# iOS Build Server
|
||||||
|
|
||||||
|
FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store builds with web UI, real-time logs, multi-user auth, and health monitoring.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy (SQLite), JWT auth
|
||||||
|
- **Frontend**: Vue 3, Vite, Vue Router
|
||||||
|
- **Tests**: pytest (backend), vitest (frontend)
|
||||||
|
- **Deploy**: macOS launchd, deploy.sh management script
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh # Dev mode (backend + frontend hot reload)
|
||||||
|
./deploy.sh build # Install deps + build frontend
|
||||||
|
./deploy.sh start/stop/restart/status
|
||||||
|
./deploy.sh test # Run all tests (backend + frontend)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `backend/routers/` — API routes: auth, users, config, apps, tasks
|
||||||
|
- `backend/services/` — build_service (packaging), build_queue (async queue), log_streamer (WebSocket)
|
||||||
|
- `backend/deps.py` — JWT auth dependency used by all protected routes
|
||||||
|
- `backend/config.py` — loads .env, defines all config constants
|
||||||
|
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
|
||||||
|
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
|
||||||
|
- Admin-only routes: user management (`/api/users/*`)
|
||||||
|
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
|
||||||
|
- Chinese UI and documentation throughout
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
- [环境要求](#环境要求)
|
- [环境要求](#环境要求)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [配置说明](#配置说明)
|
- [配置说明](#配置说明)
|
||||||
|
- [公网部署](#公网部署)
|
||||||
- [服务管理](#服务管理)
|
- [服务管理](#服务管理)
|
||||||
- [多用户管理](#多用户管理)
|
- [多用户管理](#多用户管理)
|
||||||
- [Watchdog 健康监测](#watchdog-健康监测)
|
- [Watchdog 健康监测](#watchdog-健康监测)
|
||||||
@@ -72,11 +73,11 @@ ADMIN_PASSWORD=your_secure_password
|
|||||||
|
|
||||||
启动后访问 `http://<服务器IP>:8000` 即可使用。
|
启动后访问 `http://<服务器IP>:8000` 即可使用。
|
||||||
|
|
||||||
### 4.(可选)初始化分支源码目录
|
### 4.(可选)初始化源码目录
|
||||||
|
|
||||||
系统现在默认直接从分支源码目录打包,每个分支对应 `GIT_SOURCE_BASE` 下的一个子目录。
|
配置 `GIT_REMOTE_URL` 后,系统默认使用唯一的共享源码目录 `GIT_SOURCE_DIR`。任务会串行执行拉取、切分支、清理和复制,复制完成后在各自的打包目录中并行构建。
|
||||||
|
|
||||||
如果已配置 `GIT_REMOTE_URL`,首次打包某个分支时会自动 clone;如果未配置,则需要先手动准备分支目录:
|
未配置远程仓库时,需要继续手动准备每个分支目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建分支源码根目录
|
# 创建分支源码根目录
|
||||||
@@ -86,7 +87,7 @@ mkdir -p /path/to/ReadoorBranches
|
|||||||
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
||||||
```
|
```
|
||||||
|
|
||||||
如果已配置 `GIT_REMOTE_URL`,其他分支无需手动 clone,首次打包时会自动拉取。
|
配置远程仓库时,无需手动 clone;首次打包会初始化共享目录。
|
||||||
|
|
||||||
### 5.(可选)启用健康监测
|
### 5.(可选)启用健康监测
|
||||||
|
|
||||||
@@ -110,9 +111,12 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
|||||||
|
|
||||||
| 变量 | 示例 | 说明 |
|
| 变量 | 示例 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 分支源码根目录,每个分支一个子目录,仅提供 iOS 工程源码 |
|
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 人工维护模式下的分支源码根目录 |
|
||||||
|
| `GIT_SOURCE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches/workspace` | 配置远程仓库时唯一的共享源码工作目录 |
|
||||||
| `BUILD_BASE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/build` | 打包产物输出基础目录 |
|
| `BUILD_BASE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/build` | 打包产物输出基础目录 |
|
||||||
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,分支目录不存在时自动 clone |
|
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,首次构建时自动初始化共享源码目录 |
|
||||||
|
|
||||||
|
私有仓库请创建仅有 `read_repository` 权限的 Personal Access Token,并配置为 `GIT_PASSWORD`;`GIT_USERNAME` 使用该 Token 所属账号。不要在 `.env`、`config.json` 或 Git 仓库中保存账号登录密码。
|
||||||
|
|
||||||
### 服务配置
|
### 服务配置
|
||||||
|
|
||||||
@@ -123,6 +127,25 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
|||||||
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
|
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
|
||||||
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
|
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
|
||||||
|
|
||||||
|
## 公网部署
|
||||||
|
|
||||||
|
不要将 Uvicorn 端口直接暴露到公网。使用 Nginx 或 Caddy 提供 HTTPS,服务仅监听 `127.0.0.1`。项目提供了 [nginx.conf.example](/Users/shen/Work/Code/iOSBuildServer/deploy/nginx.conf.example) 作为模板。
|
||||||
|
|
||||||
|
公网 `.env` 至少需要如下配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_ENV=production
|
||||||
|
BIND_HOST=127.0.0.1
|
||||||
|
JWT_SECRET=<至少 32 位随机字符串>
|
||||||
|
ADMIN_PASSWORD=<至少 12 位强密码>
|
||||||
|
CORS_ALLOWED_ORIGINS=https://build.example.com
|
||||||
|
TRUSTED_HOSTS=build.example.com
|
||||||
|
GIT_USERNAME=<只读 Token 所属账号>
|
||||||
|
GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|
||||||
|
```
|
||||||
|
|
||||||
|
设置完成后执行 `chmod 600 .env`,并在云安全组和系统防火墙中只开放 `80/443`。`APP_ENV=production` 会在默认 JWT、弱管理员密码、通配 CORS 或未设置可信域名时拒绝启动。
|
||||||
|
|
||||||
### 打包配置
|
### 打包配置
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
@@ -541,22 +564,24 @@ cp .env.example .env
|
|||||||
|
|
||||||
### Q: 如何按分支打包
|
### Q: 如何按分支打包
|
||||||
|
|
||||||
1. 在 `.env` 中配置 `GIT_SOURCE_BASE` 和 `GIT_REMOTE_URL`
|
1. 在 `.env` 中配置 `GIT_SOURCE_BASE`、`GIT_SOURCE_DIR` 和 `GIT_REMOTE_URL`
|
||||||
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`)
|
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`)
|
||||||
3. 打包时在「代码分支」下拉框中选择分支
|
3. 打包时在「代码分支」下拉框中选择分支
|
||||||
4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新
|
4. 首次打包会初始化共享源码目录;每次任务会串行 `git fetch`、切换目标分支、清理并复制源码快照
|
||||||
5. 每个分支有独立的源码目录,支持并行打包不同分支
|
5. 快照复制完成后,依赖安装和 Xcode 打包仍在独立目录中并行执行
|
||||||
|
|
||||||
### Q: 分支目录占满磁盘怎么办
|
### Q: 源码目录占满磁盘怎么办
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看各分支目录大小
|
# 远程仓库模式下只有一个共享工作目录
|
||||||
du -sh /path/to/ReadoorBranches/*
|
du -sh /path/to/ReadoorBranches/workspace
|
||||||
|
|
||||||
# 删除不用的分支目录
|
# 打包目录由 BUILD_DIR_RETENTION_HOURS 自动清理;可查看其占用
|
||||||
rm -rf /path/to/ReadoorBranches/old-branch
|
du -sh /path/to/build/build_readoor_*
|
||||||
```
|
```
|
||||||
|
|
||||||
|
共享工作目录由服务在每次打包前自动同步和清理。请勿在服务运行期间手动删除或修改该目录。
|
||||||
|
|
||||||
### Q: 如何运行测试
|
### Q: 如何运行测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+40
-5
@@ -47,10 +47,11 @@ SKINS_DIR = DATA_DIR / "skins"
|
|||||||
# 打包基础目录
|
# 打包基础目录
|
||||||
BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build")))
|
BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build")))
|
||||||
|
|
||||||
# Git 分支源码目录
|
# Git 源码目录
|
||||||
# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录
|
# 配置 GIT_REMOTE_URL 时,所有分支共用 GIT_SOURCE_DIR;构建前会串行切换分支并复制快照。
|
||||||
# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone
|
# 未配置远程仓库时,继续兼容 GIT_SOURCE_BASE/<branch> 的人工维护目录。
|
||||||
GIT_SOURCE_BASE = _resolve_server_path(os.getenv("GIT_SOURCE_BASE", str(SERVER_ROOT / "ReadoorBranches")))
|
GIT_SOURCE_BASE = _resolve_server_path(os.getenv("GIT_SOURCE_BASE", str(SERVER_ROOT / "ReadoorBranches")))
|
||||||
|
GIT_SOURCE_DIR = _resolve_server_path(os.getenv("GIT_SOURCE_DIR", str(GIT_SOURCE_BASE / "workspace")))
|
||||||
GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "")
|
GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "")
|
||||||
GIT_USERNAME = os.getenv("GIT_USERNAME", "")
|
GIT_USERNAME = os.getenv("GIT_USERNAME", "")
|
||||||
GIT_PASSWORD = os.getenv("GIT_PASSWORD", "")
|
GIT_PASSWORD = os.getenv("GIT_PASSWORD", "")
|
||||||
@@ -89,17 +90,23 @@ def mask_git_remote_url(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_source_dir(branch: str) -> Path:
|
def get_source_dir(branch: str) -> Path:
|
||||||
"""获取指定分支的源码目录"""
|
"""获取人工维护模式下指定分支的源码目录。"""
|
||||||
return GIT_SOURCE_BASE / branch
|
return GIT_SOURCE_BASE / branch
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_source_dir() -> Path:
|
||||||
|
"""获取配置远程仓库时使用的唯一共享源码工作目录。"""
|
||||||
|
return GIT_SOURCE_DIR
|
||||||
|
|
||||||
|
|
||||||
# 需要拷贝的目录和文件
|
# 需要拷贝的目录和文件
|
||||||
COPY_ITEMS = [
|
COPY_ITEMS = [
|
||||||
"readoor",
|
"readoor",
|
||||||
"readoor.xcodeproj",
|
"readoor.xcodeproj",
|
||||||
"readoorTests",
|
"readoorTests",
|
||||||
"Vendor",
|
"Vendor",
|
||||||
"podfile",
|
"AutoPacking",
|
||||||
|
"Podfile",
|
||||||
"Pods",
|
"Pods",
|
||||||
"Podfile.lock",
|
"Podfile.lock",
|
||||||
"readoor.xcworkspace",
|
"readoor.xcworkspace",
|
||||||
@@ -108,6 +115,14 @@ COPY_ITEMS = [
|
|||||||
# 服务端口
|
# 服务端口
|
||||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
||||||
|
|
||||||
|
# 公网部署安全配置。开发环境保持低门槛,生产环境会在启动时强制校验关键项。
|
||||||
|
APP_ENV = os.getenv("APP_ENV", "development").lower()
|
||||||
|
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if origin.strip()]
|
||||||
|
TRUSTED_HOSTS = [host.strip() for host in os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver").split(",") if host.strip()]
|
||||||
|
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = int(os.getenv("LOGIN_RATE_LIMIT_MAX_ATTEMPTS", "5"))
|
||||||
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300"))
|
||||||
|
LOGIN_RATE_LIMIT_LOCKOUT_SECONDS = int(os.getenv("LOGIN_RATE_LIMIT_LOCKOUT_SECONDS", "900"))
|
||||||
|
|
||||||
# 数据库路径。测试可通过 DATABASE_URL 注入临时 SQLite,避免影响运行库。
|
# 数据库路径。测试可通过 DATABASE_URL 注入临时 SQLite,避免影响运行库。
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{Path(__file__).parent / 'build_server.db'}")
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{Path(__file__).parent / 'build_server.db'}")
|
||||||
|
|
||||||
@@ -131,3 +146,23 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
|||||||
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRE_HOURS = 24
|
JWT_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def validate_production_security() -> None:
|
||||||
|
"""阻止带默认凭据或无访问边界的生产服务启动。"""
|
||||||
|
if APP_ENV != "production":
|
||||||
|
return
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if (JWT_SECRET == "ios-build-server-secret-key-change-in-production"
|
||||||
|
or JWT_SECRET.startswith("replace-") or len(JWT_SECRET) < 32):
|
||||||
|
errors.append("JWT_SECRET 必须设置为至少 32 位的随机字符串")
|
||||||
|
if (ADMIN_PASSWORD == "admin123" or ADMIN_PASSWORD.startswith("change-")
|
||||||
|
or len(ADMIN_PASSWORD) < 11):
|
||||||
|
errors.append("ADMIN_PASSWORD 必须设置为至少 11 位的强密码")
|
||||||
|
if not CORS_ALLOWED_ORIGINS or "*" in CORS_ALLOWED_ORIGINS:
|
||||||
|
errors.append("CORS_ALLOWED_ORIGINS 必须设置为实际 HTTPS 前端域名,且不能为 *")
|
||||||
|
if not os.getenv("TRUSTED_HOSTS") or "*" in TRUSTED_HOSTS:
|
||||||
|
errors.append("TRUSTED_HOSTS 必须设置为实际服务域名")
|
||||||
|
if errors:
|
||||||
|
raise RuntimeError("生产环境安全配置不完整: " + "; ".join(errors))
|
||||||
|
|||||||
+3
-3
@@ -38,13 +38,13 @@ def _seed_admin():
|
|||||||
"""初始化管理员账号(从 .env 配置)"""
|
"""初始化管理员账号(从 .env 配置)"""
|
||||||
from .models import User
|
from .models import User
|
||||||
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
|
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
|
||||||
import hashlib
|
from .security import hash_password
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
|
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
|
||||||
pw_hash = hashlib.sha256(ADMIN_PASSWORD.encode()).hexdigest()
|
db.add(User(id="admin-001", username=ADMIN_USERNAME,
|
||||||
db.add(User(id="admin-001", username=ADMIN_USERNAME, password_hash=pw_hash, is_admin=True))
|
password_hash=hash_password(ADMIN_PASSWORD), is_admin=True))
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
+14
-3
@@ -8,9 +8,8 @@ from .config import JWT_SECRET, JWT_ALGORITHM
|
|||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
def decode_current_user(token: str) -> dict:
|
||||||
"""验证 JWT token,返回用户信息"""
|
"""验证 JWT token,返回用户身份。"""
|
||||||
token = credentials.credentials
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
|
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
|
||||||
@@ -18,3 +17,15 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||||
|
"""验证 HTTP Bearer JWT token,返回用户信息。"""
|
||||||
|
return decode_current_user(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
"""限制仅管理员可访问的管理和敏感配置接口。"""
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
|
return user
|
||||||
|
|||||||
+29
-12
@@ -1,15 +1,17 @@
|
|||||||
"""FastAPI 主入口"""
|
"""FastAPI 主入口"""
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import config, apps, tasks, auth, users
|
from .routers import config, apps, tasks, auth, users
|
||||||
from .services.log_streamer import log_streamer
|
from .services.log_streamer import log_streamer
|
||||||
from .config import BACKEND_PORT
|
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
|
||||||
|
from .deps import decode_current_user
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ STATIC_DIR = Path(__file__).parent / "static"
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期"""
|
"""应用生命周期"""
|
||||||
|
validate_production_security()
|
||||||
init_db()
|
init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -27,14 +30,17 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS
|
# 仅接受显式允许的跨域前端;同域部署不需要 CORS 配置。
|
||||||
app.add_middleware(
|
if CORS_ALLOWED_ORIGINS:
|
||||||
CORSMiddleware,
|
app.add_middleware(
|
||||||
allow_origins=["*"],
|
CORSMiddleware,
|
||||||
allow_credentials=True,
|
allow_origins=CORS_ALLOWED_ORIGINS,
|
||||||
allow_methods=["*"],
|
allow_credentials=False,
|
||||||
allow_headers=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
)
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=TRUSTED_HOSTS)
|
||||||
|
|
||||||
# 路由
|
# 路由
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@@ -46,8 +52,19 @@ app.include_router(tasks.router)
|
|||||||
|
|
||||||
@app.websocket("/ws/tasks/{task_id}")
|
@app.websocket("/ws/tasks/{task_id}")
|
||||||
async def websocket_logs(websocket: WebSocket, task_id: str):
|
async def websocket_logs(websocket: WebSocket, task_id: str):
|
||||||
"""WebSocket 实时日志"""
|
"""经 JWT 鉴权的 WebSocket 实时日志。"""
|
||||||
await websocket.accept()
|
protocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||||
|
token_protocol = next((item.strip() for item in protocol.split(",") if item.strip().startswith("jwt.")), "")
|
||||||
|
if not token_protocol:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
decode_current_user(token_protocol.removeprefix("jwt."))
|
||||||
|
except Exception:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept(subprotocol=token_protocol)
|
||||||
try:
|
try:
|
||||||
async for msg in log_streamer.subscribe(task_id):
|
async for msg in log_streamer.subscribe(task_id):
|
||||||
await websocket.send_json(msg)
|
await websocket.send_json(msg)
|
||||||
|
|||||||
+20
-1
@@ -5,12 +5,31 @@ from .config import load_config
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["apps"])
|
router = APIRouter(prefix="/api", tags=["apps"])
|
||||||
|
|
||||||
|
# 每个 App 都只能使用明确允许的 Scheme。
|
||||||
|
_APP_SCHEME_RULES = {
|
||||||
|
"申学": ("readoorShenXue",),
|
||||||
|
"申学APP": ("readoorShenXue",),
|
||||||
|
"英汉大词典测试": ("readoorDict",),
|
||||||
|
"英汉大词典": ("readoorDict",),
|
||||||
|
}
|
||||||
|
_DEFAULT_SCHEME_NAMES = ("readoor31", "readoor31OtherPay")
|
||||||
|
|
||||||
|
|
||||||
|
def get_allowed_scheme_names(app: dict) -> tuple[str, ...]:
|
||||||
|
"""返回 App 可使用的 Scheme 名称。"""
|
||||||
|
return _APP_SCHEME_RULES.get(app.get("name", ""), _DEFAULT_SCHEME_NAMES)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/apps")
|
@router.get("/apps")
|
||||||
async def get_apps_for_build():
|
async def get_apps_for_build():
|
||||||
"""获取 apps 列表(供打包选择)"""
|
"""获取 apps 列表(供打包选择)"""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
return config.get("apps", {})
|
apps = {}
|
||||||
|
for app_id, app in config.get("apps", {}).items():
|
||||||
|
app_data = app.copy()
|
||||||
|
app_data["allowed_scheme_names"] = list(get_allowed_scheme_names(app))
|
||||||
|
apps[app_id] = app_data
|
||||||
|
return apps
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schemes")
|
@router.get("/schemes")
|
||||||
|
|||||||
+47
-9
@@ -1,16 +1,24 @@
|
|||||||
"""认证 API"""
|
"""认证 API"""
|
||||||
import jwt
|
import jwt
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..schemas import LoginRequest, LoginResponse
|
from ..schemas import LoginRequest, LoginResponse
|
||||||
from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
|
from ..config import (
|
||||||
|
JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS,
|
||||||
|
LOGIN_RATE_LIMIT_MAX_ATTEMPTS, LOGIN_RATE_LIMIT_WINDOW_SECONDS, LOGIN_RATE_LIMIT_LOCKOUT_SECONDS,
|
||||||
|
)
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from ..security import hash_password, verify_password
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
_login_attempts: dict[str, tuple[int, float, float]] = {}
|
||||||
|
_login_attempts_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _create_token(username: str, is_admin: bool) -> str:
|
def _create_token(username: str, is_admin: bool) -> str:
|
||||||
@@ -23,19 +31,49 @@ def _create_token(username: str, is_admin: bool) -> str:
|
|||||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def _verify_password(password: str, password_hash: str) -> bool:
|
def _check_login_rate_limit(client_ip: str) -> None:
|
||||||
"""校验密码"""
|
"""按来源 IP 限制登录失败次数,降低在线暴力破解风险。"""
|
||||||
import hashlib
|
now = time.monotonic()
|
||||||
return hashlib.sha256(password.encode()).hexdigest() == password_hash
|
with _login_attempts_lock:
|
||||||
|
attempts, first_failure, locked_until = _login_attempts.get(client_ip, (0, now, 0))
|
||||||
|
if locked_until > now:
|
||||||
|
raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试")
|
||||||
|
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
|
||||||
|
_login_attempts.pop(client_ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_login_failure(client_ip: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _login_attempts_lock:
|
||||||
|
attempts, first_failure, _ = _login_attempts.get(client_ip, (0, now, 0))
|
||||||
|
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
|
||||||
|
attempts, first_failure = 0, now
|
||||||
|
attempts += 1
|
||||||
|
locked_until = now + LOGIN_RATE_LIMIT_LOCKOUT_SECONDS if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS else 0
|
||||||
|
_login_attempts[client_ip] = (attempts, first_failure, locked_until)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_login_failures(client_ip: str) -> None:
|
||||||
|
with _login_attempts_lock:
|
||||||
|
_login_attempts.pop(client_ip, None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
async def login(credentials: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
"""用户登录"""
|
"""用户登录"""
|
||||||
user = db.query(User).filter(User.username == request.username).first()
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
if not user or not _verify_password(request.password, user.password_hash):
|
_check_login_rate_limit(client_ip)
|
||||||
|
user = db.query(User).filter(User.username == credentials.username).first()
|
||||||
|
valid, needs_upgrade = verify_password(credentials.password, user.password_hash) if user else (False, False)
|
||||||
|
if not user or not valid:
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
if needs_upgrade:
|
||||||
|
user.password_hash = hash_password(credentials.password)
|
||||||
|
db.commit()
|
||||||
|
_clear_login_failures(client_ip)
|
||||||
|
|
||||||
token = _create_token(user.username, user.is_admin)
|
token = _create_token(user.username, user.is_admin)
|
||||||
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
|
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
|
||||||
|
|
||||||
|
|||||||
+125
-23
@@ -2,11 +2,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -21,9 +22,19 @@ from ..config import (
|
|||||||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||||||
SKINS_DIR,
|
SKINS_DIR,
|
||||||
)
|
)
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user, require_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)])
|
|
||||||
|
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
"""普通用户可管理 Apps 与版本号;其余配置及凭据只允许管理员访问。"""
|
||||||
|
path = request.url.path.rstrip("/")
|
||||||
|
if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
|
||||||
|
or path == "/api/config/versions"):
|
||||||
|
return user
|
||||||
|
return require_admin(user)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(_require_config_permission)])
|
||||||
|
|
||||||
_config_lock = asyncio.Lock()
|
_config_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -56,22 +67,26 @@ DEFAULT_SERVERS = {
|
|||||||
"测试环境": {
|
"测试环境": {
|
||||||
"api": "https://api3-dev.readoor.cn",
|
"api": "https://api3-dev.readoor.cn",
|
||||||
"assDom": "applinks:dev-data1.readoor.cn",
|
"assDom": "applinks:dev-data1.readoor.cn",
|
||||||
"universalLink": "https://dev-data1.readoor.cn"
|
"universalLink": "https://dev-data1.readoor.cn",
|
||||||
|
"app_id_prefix": 1,
|
||||||
},
|
},
|
||||||
"正式环境": {
|
"正式环境": {
|
||||||
"api": "https://api3.readoor.cn",
|
"api": "https://api3.readoor.cn",
|
||||||
"assDom": "applinks:data1.readoor.cn",
|
"assDom": "applinks:data1.readoor.cn",
|
||||||
"universalLink": "https://data1.readoor.cn"
|
"universalLink": "https://data1.readoor.cn",
|
||||||
|
"app_id_prefix": 2,
|
||||||
},
|
},
|
||||||
"华师大环境": {
|
"华师大环境": {
|
||||||
"api": "https://api3.ecnupress.com.cn",
|
"api": "https://api3.ecnupress.com.cn",
|
||||||
"assDom": "applinks:data1.ecnupress.com.cn",
|
"assDom": "applinks:data1.ecnupress.com.cn",
|
||||||
"universalLink": "https://data1.ecnupress.com.cn"
|
"universalLink": "https://data1.ecnupress.com.cn",
|
||||||
|
"app_id_prefix": 3,
|
||||||
},
|
},
|
||||||
"外教环境": {
|
"外教环境": {
|
||||||
"api": "https://weread-api3.sflep.com/api3",
|
"api": "https://weread-api3.sflep.com/api3",
|
||||||
"assDom": "applinks:wereadossda.sflep.com",
|
"assDom": "applinks:wereadossda.sflep.com",
|
||||||
"universalLink": "https://wereadossda.sflep.com"
|
"universalLink": "https://wereadossda.sflep.com",
|
||||||
|
"app_id_prefix": 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +95,84 @@ DEFAULT_VERSIONS = {
|
|||||||
"build_ver": "2.180.0.0",
|
"build_ver": "2.180.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
|
||||||
|
LEGACY_SERVER_PREFIXES = {
|
||||||
|
"测试环境": 1,
|
||||||
|
"正式环境": 2,
|
||||||
|
"华师大环境": 3,
|
||||||
|
"外教环境": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_server_id_prefixes(config: dict) -> bool:
|
||||||
|
"""为旧配置补齐环境 ID 前缀和下一个可分配前缀。"""
|
||||||
|
changed = False
|
||||||
|
servers = config.get("servers", {})
|
||||||
|
used_prefixes = set()
|
||||||
|
for name, server in servers.items():
|
||||||
|
prefix = server.get("app_id_prefix")
|
||||||
|
if prefix is None and name in LEGACY_SERVER_PREFIXES:
|
||||||
|
prefix = LEGACY_SERVER_PREFIXES[name]
|
||||||
|
server["app_id_prefix"] = prefix
|
||||||
|
changed = True
|
||||||
|
if isinstance(prefix, int) and prefix > 0:
|
||||||
|
used_prefixes.add(prefix)
|
||||||
|
|
||||||
|
next_prefix = max(used_prefixes, default=0) + 1
|
||||||
|
for server in servers.values():
|
||||||
|
prefix = server.get("app_id_prefix")
|
||||||
|
if not isinstance(prefix, int) or prefix <= 0:
|
||||||
|
server["app_id_prefix"] = next_prefix
|
||||||
|
used_prefixes.add(next_prefix)
|
||||||
|
next_prefix += 1
|
||||||
|
changed = True
|
||||||
|
if not isinstance(config.get("next_app_id_prefix"), int) or config["next_app_id_prefix"] < next_prefix:
|
||||||
|
config["next_app_id_prefix"] = next_prefix
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_special_app_prefix_overrides(config: dict) -> bool:
|
||||||
|
"""将英汉大词典的历史 1xx 规则迁移为显式的 App 前缀覆盖。"""
|
||||||
|
changed = False
|
||||||
|
for app in config.get("apps", {}).values():
|
||||||
|
if "英汉大词典" in app.get("name", "") and not app.get("app_id_prefix_override"):
|
||||||
|
app["app_id_prefix_override"] = 1
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_special_app_prefix_override(app: dict) -> None:
|
||||||
|
"""英汉大词典沿用历史 1xx 编号段。"""
|
||||||
|
if "英汉大词典" in app.get("name", "") and not app.get("app_id_prefix_override"):
|
||||||
|
app["app_id_prefix_override"] = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
|
||||||
|
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
|
||||||
|
prefix = app.get("app_id_prefix_override")
|
||||||
|
if prefix in (None, ""):
|
||||||
|
server = servers.get(app.get("server", ""), {})
|
||||||
|
prefix = server.get("app_id_prefix")
|
||||||
|
try:
|
||||||
|
prefix = int(prefix)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
prefix = 0
|
||||||
|
if prefix <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择已配置 Apps ID 规则的服务器环境")
|
||||||
|
|
||||||
|
prefix_text = str(prefix)
|
||||||
|
serials = [
|
||||||
|
int(app_id[len(prefix_text):])
|
||||||
|
for app_id in apps
|
||||||
|
if (app_id.isdigit() and app_id.startswith(prefix_text)
|
||||||
|
and len(app_id) == len(prefix_text) + 2)
|
||||||
|
]
|
||||||
|
next_serial = max(serials, default=-1) + 1
|
||||||
|
if next_serial > 99:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Apps ID 前缀 {prefix} 的编号已用完")
|
||||||
|
return f"{prefix}{next_serial:02d}"
|
||||||
|
|
||||||
|
|
||||||
def _ensure_upload_keys(config: dict) -> bool:
|
def _ensure_upload_keys(config: dict) -> bool:
|
||||||
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
|
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
|
||||||
@@ -141,7 +234,10 @@ def load_config() -> dict:
|
|||||||
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
|
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
|
||||||
else:
|
else:
|
||||||
return {"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS, "branches": ["main"], "upload": DEFAULT_UPLOAD}
|
return {
|
||||||
|
"apps": {}, "schemes": {}, "servers": DEFAULT_SERVERS,
|
||||||
|
"next_app_id_prefix": 5, "branches": ["main"], "upload": DEFAULT_UPLOAD,
|
||||||
|
}
|
||||||
with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
|
with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
# 确保必要字段存在
|
# 确保必要字段存在
|
||||||
@@ -153,8 +249,11 @@ def load_config() -> dict:
|
|||||||
config["upload"] = DEFAULT_UPLOAD
|
config["upload"] = DEFAULT_UPLOAD
|
||||||
if "versions" not in config:
|
if "versions" not in config:
|
||||||
config["versions"] = DEFAULT_VERSIONS.copy()
|
config["versions"] = DEFAULT_VERSIONS.copy()
|
||||||
# 自动为缺少 upload_key 的 app 生成唯一标识
|
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
|
||||||
if _ensure_upload_keys(config):
|
prefixes_changed = _ensure_server_id_prefixes(config)
|
||||||
|
overrides_changed = _ensure_special_app_prefix_overrides(config)
|
||||||
|
keys_changed = _ensure_upload_keys(config)
|
||||||
|
if prefixes_changed or overrides_changed or keys_changed:
|
||||||
save_config(config)
|
save_config(config)
|
||||||
# 迁移旧式目录皮肤为 ZIP
|
# 迁移旧式目录皮肤为 ZIP
|
||||||
if _migrate_old_themes(config):
|
if _migrate_old_themes(config):
|
||||||
@@ -206,9 +305,9 @@ async def create_app(app: dict):
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
apps = config.get("apps", {})
|
apps = config.get("apps", {})
|
||||||
|
|
||||||
# 自动生成 ID
|
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
|
||||||
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
|
_apply_special_app_prefix_override(app)
|
||||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
new_id = _next_app_id(apps, config.get("servers", {}), app)
|
||||||
|
|
||||||
# 自动生成 upload_key
|
# 自动生成 upload_key
|
||||||
if not app.get("upload_key"):
|
if not app.get("upload_key"):
|
||||||
@@ -491,19 +590,18 @@ async def get_versions():
|
|||||||
|
|
||||||
@router.put("/versions")
|
@router.put("/versions")
|
||||||
async def update_versions(data: dict):
|
async def update_versions(data: dict):
|
||||||
"""更新 App_Ver 和 Build_Ver"""
|
"""更新 App_Ver,并将 Build_Ver 重置为对应的 .0。"""
|
||||||
app_ver = data.get("app_ver", "").strip()
|
app_ver = data.get("app_ver", "").strip()
|
||||||
build_ver = data.get("build_ver", "").strip()
|
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
|
||||||
|
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
|
||||||
|
|
||||||
if not app_ver or not build_ver:
|
build_ver = f"{app_ver}.0"
|
||||||
raise HTTPException(status_code=400, detail="版本号不能为空")
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
config["versions"] = {
|
|
||||||
"app_ver": app_ver,
|
|
||||||
"build_ver": build_ver,
|
|
||||||
}
|
|
||||||
async with _config_lock:
|
async with _config_lock:
|
||||||
|
config = load_config()
|
||||||
|
config["versions"] = {
|
||||||
|
"app_ver": app_ver,
|
||||||
|
"build_ver": build_ver,
|
||||||
|
}
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver}
|
return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver}
|
||||||
@@ -548,12 +646,15 @@ async def create_server(server_data: dict):
|
|||||||
if name in servers:
|
if name in servers:
|
||||||
raise HTTPException(status_code=400, detail="环境名称已存在")
|
raise HTTPException(status_code=400, detail="环境名称已存在")
|
||||||
|
|
||||||
|
prefix = config.get("next_app_id_prefix", 1)
|
||||||
servers[name] = {
|
servers[name] = {
|
||||||
"api": server_data.get("api", ""),
|
"api": server_data.get("api", ""),
|
||||||
"assDom": server_data.get("assDom", ""),
|
"assDom": server_data.get("assDom", ""),
|
||||||
"universalLink": server_data.get("universalLink", ""),
|
"universalLink": server_data.get("universalLink", ""),
|
||||||
|
"app_id_prefix": prefix,
|
||||||
}
|
}
|
||||||
config["servers"] = servers
|
config["servers"] = servers
|
||||||
|
config["next_app_id_prefix"] = prefix + 1
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"message": "服务器环境已创建"}
|
return {"message": "服务器环境已创建"}
|
||||||
|
|
||||||
@@ -581,6 +682,7 @@ async def update_server(server_name: str, server_data: dict):
|
|||||||
"api": server_data.get("api", ""),
|
"api": server_data.get("api", ""),
|
||||||
"assDom": server_data.get("assDom", ""),
|
"assDom": server_data.get("assDom", ""),
|
||||||
"universalLink": server_data.get("universalLink", ""),
|
"universalLink": server_data.get("universalLink", ""),
|
||||||
|
"app_id_prefix": servers[new_name].get("app_id_prefix"),
|
||||||
}
|
}
|
||||||
config["servers"] = servers
|
config["servers"] = servers
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""任务 API"""
|
"""任务 API"""
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
@@ -34,6 +35,20 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
app = apps[task.app_id]
|
app = apps[task.app_id]
|
||||||
scheme = schemes[task.scheme_id]
|
scheme = schemes[task.scheme_id]
|
||||||
|
scheme_name = scheme.get("name", "")
|
||||||
|
|
||||||
|
if task.build_type not in {"Ad_Hoc", "App_Store"}:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的打包类型")
|
||||||
|
if not app.get("certificates", {}).get(task.build_type):
|
||||||
|
raise HTTPException(status_code=400, detail=f"该 App 未配置 {task.build_type} 证书")
|
||||||
|
|
||||||
|
from .apps import get_allowed_scheme_names
|
||||||
|
allowed_scheme_names = get_allowed_scheme_names(app)
|
||||||
|
if allowed_scheme_names and scheme_name not in allowed_scheme_names:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
|
||||||
|
)
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
db_task = Task(
|
db_task = Task(
|
||||||
@@ -42,7 +57,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
|||||||
app_name=app.get("name", ""),
|
app_name=app.get("name", ""),
|
||||||
build_type=task.build_type,
|
build_type=task.build_type,
|
||||||
scheme_id=task.scheme_id,
|
scheme_id=task.scheme_id,
|
||||||
scheme_name=scheme.get("displayName") or scheme.get("name", ""),
|
scheme_name=scheme.get("displayName") or scheme_name,
|
||||||
obfuscation=task.obfuscation,
|
obfuscation=task.obfuscation,
|
||||||
branch=task.branch,
|
branch=task.branch,
|
||||||
status="pending",
|
status="pending",
|
||||||
@@ -150,6 +165,29 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
|||||||
if task.status in ("running", "pending"):
|
if task.status in ("running", "pending"):
|
||||||
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
|
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
|
||||||
|
|
||||||
|
# 同一个 App / 版本 / 分支重复打包会覆盖并复用同一个远端文件;
|
||||||
|
# 只要仍有其他历史记录引用该下载地址,就不能删除远端产物。
|
||||||
|
has_shared_artifact = bool(
|
||||||
|
task.oss_url and db.query(Task).filter(
|
||||||
|
Task.id != task.id,
|
||||||
|
Task.oss_url == task.oss_url,
|
||||||
|
).first()
|
||||||
|
)
|
||||||
|
if task.oss_url and not has_shared_artifact:
|
||||||
|
if not task.config_json:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端文件")
|
||||||
|
try:
|
||||||
|
build_config = json.loads(task.config_json)
|
||||||
|
from .config import load_config
|
||||||
|
from ..services.distribution import DistributionError, delete_published_artifacts
|
||||||
|
delete_published_artifacts(
|
||||||
|
build_config,
|
||||||
|
load_config().get("upload", {}),
|
||||||
|
task.oss_url,
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, DistributionError) as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
|
||||||
|
|
||||||
# 删除日志文件
|
# 删除日志文件
|
||||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||||
if log_path.exists():
|
if log_path.exists():
|
||||||
@@ -164,7 +202,10 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
|||||||
db.delete(task)
|
db.delete(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "记录已删除"}
|
message = "记录已删除"
|
||||||
|
if task.oss_url:
|
||||||
|
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}/log")
|
@router.get("/{task_id}/log")
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""用户管理 API(仅管理员)"""
|
"""用户管理 API(仅管理员)"""
|
||||||
import uuid
|
import uuid
|
||||||
import hashlib
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
|
from ..security import hash_password
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
@@ -16,10 +16,6 @@ def _require_admin(user: dict):
|
|||||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""获取用户列表"""
|
"""获取用户列表"""
|
||||||
@@ -39,15 +35,15 @@ async def create_user(data: dict, user: dict = Depends(get_current_user), db: Se
|
|||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
|
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
|
||||||
if len(password) < 6:
|
if len(password) < 11:
|
||||||
raise HTTPException(status_code=400, detail="密码至少 6 位")
|
raise HTTPException(status_code=400, detail="密码至少 11 位")
|
||||||
if db.query(User).filter(User.username == username).first():
|
if db.query(User).filter(User.username == username).first():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=_hash_password(password),
|
password_hash=hash_password(password),
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
@@ -65,10 +61,10 @@ async def change_password(user_id: str, data: dict, user: dict = Depends(get_cur
|
|||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
new_password = data.get("password", "").strip()
|
new_password = data.get("password", "").strip()
|
||||||
if not new_password or len(new_password) < 6:
|
if not new_password or len(new_password) < 11:
|
||||||
raise HTTPException(status_code=400, detail="密码至少 6 位")
|
raise HTTPException(status_code=400, detail="密码至少 11 位")
|
||||||
|
|
||||||
target.password_hash = _hash_password(new_password)
|
target.password_hash = hash_password(new_password)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "密码已更新"}
|
return {"message": "密码已更新"}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""认证相关的密码散列与兼容迁移工具。"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""使用 bcrypt 保存密码,避免快速散列被离线撞库。"""
|
||||||
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored_hash: str) -> tuple[bool, bool]:
|
||||||
|
"""返回 (密码是否正确, 是否需要将旧 SHA-256 散列升级为 bcrypt)。"""
|
||||||
|
if stored_hash.startswith("$2"):
|
||||||
|
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8")), False
|
||||||
|
|
||||||
|
legacy_hash = hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
return hmac.compare_digest(legacy_hash, stored_hash), True
|
||||||
+251
-141
@@ -2,7 +2,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import plistlib
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,11 +18,13 @@ from ..config import (
|
|||||||
SKINS_DIR,
|
SKINS_DIR,
|
||||||
AUTOMATION_DIR,
|
AUTOMATION_DIR,
|
||||||
get_source_dir,
|
get_source_dir,
|
||||||
|
get_shared_source_dir,
|
||||||
get_git_remote_url,
|
get_git_remote_url,
|
||||||
mask_git_remote_url,
|
mask_git_remote_url,
|
||||||
)
|
)
|
||||||
from .log_streamer import log_streamer
|
from .log_streamer import log_streamer
|
||||||
from .distribution import DistributionError, publish_ipa
|
from .distribution import DistributionError, publish_ipa
|
||||||
|
from .notification import NotificationError, send_dingtalk_notification
|
||||||
from .project_patcher import ProjectPatchError, apply_project_config
|
from .project_patcher import ProjectPatchError, apply_project_config
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +37,103 @@ class BuildError(Exception):
|
|||||||
self.detail = detail
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
# 共享源码目录只能同时被一个任务切换、清理和复制。
|
||||||
|
# 后续在各自 build_dir 中执行的配置、依赖安装和编译不受此锁限制。
|
||||||
|
source_prepare_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_afnetworking_private_headers(build_dir: Path) -> int:
|
||||||
|
"""移除旧版 AFNetworking 对 Xcode 新 SDK 私有头的直接引用。"""
|
||||||
|
afnetworking_dir = build_dir / "Pods" / "AFNetworking"
|
||||||
|
if not afnetworking_dir.is_dir():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
private_import = "#import <netinet6/in6.h>"
|
||||||
|
patched_files = 0
|
||||||
|
for source_path in afnetworking_dir.rglob("*"):
|
||||||
|
if source_path.suffix not in {".h", ".m", ".mm"} or not source_path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = source_path.read_text(encoding="utf-8")
|
||||||
|
if private_import not in content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated_lines = [
|
||||||
|
line for line in content.splitlines(keepends=True)
|
||||||
|
if line.strip() != private_import
|
||||||
|
]
|
||||||
|
original_mode = source_path.stat().st_mode
|
||||||
|
try:
|
||||||
|
source_path.chmod(original_mode | 0o200)
|
||||||
|
source_path.write_text("".join(updated_lines), encoding="utf-8")
|
||||||
|
finally:
|
||||||
|
source_path.chmod(original_mode)
|
||||||
|
patched_files += 1
|
||||||
|
|
||||||
|
return patched_files
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_provisioning_profile(profile_path: Path) -> dict:
|
||||||
|
"""沿用 AutoPacking 脚本的 security cms 方式读取描述文件。"""
|
||||||
|
try:
|
||||||
|
plist_xml = subprocess.check_output(
|
||||||
|
["security", "cms", "-D", "-i", str(profile_path)],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
# 与原脚本一致,跳过 security 可能输出在 XML 前面的 Apple 标记行。
|
||||||
|
lines = plist_xml.splitlines(keepends=True)
|
||||||
|
xml_start = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, line in enumerate(lines)
|
||||||
|
if line.lstrip().startswith((b"<?xml", b"<!DOCTYPE"))
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return plistlib.loads(b"".join(lines[xml_start:]))
|
||||||
|
except (OSError, subprocess.CalledProcessError, plistlib.InvalidFileException) as exc:
|
||||||
|
raise BuildError(f"读取描述文件失败: {profile_path.name}", category="provisioning") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_provisioning_profile(profile_reference: str) -> tuple[str, str]:
|
||||||
|
"""按 AutoPacking 的规则从描述文件名称提取 Name 和 TeamIdentifier。"""
|
||||||
|
configured_path = Path(profile_reference).expanduser()
|
||||||
|
if configured_path.is_file():
|
||||||
|
profile = _decode_provisioning_profile(configured_path)
|
||||||
|
else:
|
||||||
|
profiles_dir = (
|
||||||
|
Path.home()
|
||||||
|
/ "Library"
|
||||||
|
/ "Developer"
|
||||||
|
/ "Xcode"
|
||||||
|
/ "UserData"
|
||||||
|
/ "Provisioning Profiles"
|
||||||
|
)
|
||||||
|
if not profiles_dir.is_dir():
|
||||||
|
raise BuildError(f"Provisioning Profiles 目录不存在: {profiles_dir}", category="provisioning")
|
||||||
|
|
||||||
|
profile = None
|
||||||
|
for candidate in profiles_dir.iterdir():
|
||||||
|
if candidate.suffix != ".mobileprovision":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate_profile = _decode_provisioning_profile(candidate)
|
||||||
|
except BuildError:
|
||||||
|
continue
|
||||||
|
if candidate_profile.get("Name") == profile_reference:
|
||||||
|
profile = candidate_profile
|
||||||
|
break
|
||||||
|
if profile is None:
|
||||||
|
raise BuildError(f"未找到名称为 [{profile_reference}] 的描述文件", category="provisioning")
|
||||||
|
|
||||||
|
profile_name = profile.get("Name", "")
|
||||||
|
team_ids = profile.get("TeamIdentifier", [])
|
||||||
|
team_id = team_ids[0] if isinstance(team_ids, list) and team_ids else team_ids
|
||||||
|
if not profile_name or not team_id:
|
||||||
|
raise BuildError(f"描述文件缺少 Name 或 TeamIdentifier: {profile_reference}", category="provisioning")
|
||||||
|
return profile_name, team_id
|
||||||
|
|
||||||
|
|
||||||
# 错误分类规则:(关键词列表, category, 友好提示)
|
# 错误分类规则:(关键词列表, category, 友好提示)
|
||||||
_ERROR_RULES = [
|
_ERROR_RULES = [
|
||||||
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
||||||
@@ -60,6 +161,16 @@ _ERROR_RULES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _git_env() -> dict:
|
||||||
|
"""为无人值守的构建任务禁用交互提示与凭据缓存写入。"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
_GIT_NO_CREDENTIAL_HELPER = ("git", "-c", "credential.helper=")
|
||||||
|
|
||||||
|
|
||||||
def _classify_build_error(output_lines: list, step: str) -> tuple:
|
def _classify_build_error(output_lines: list, step: str) -> tuple:
|
||||||
"""解析构建输出,返回 (category, friendly_message)
|
"""解析构建输出,返回 (category, friendly_message)
|
||||||
|
|
||||||
@@ -136,18 +247,20 @@ def _db_update(db, task, **fields):
|
|||||||
|
|
||||||
|
|
||||||
async def update_source(task_id: str, source_dir: Path, branch: str):
|
async def update_source(task_id: str, source_dir: Path, branch: str):
|
||||||
"""确保分支源码目录存在且为最新"""
|
"""将共享源码工作目录强制同步到指定远程分支。"""
|
||||||
remote_url = get_git_remote_url(with_credentials=True)
|
remote_url = get_git_remote_url(with_credentials=True)
|
||||||
remote_url_masked = mask_git_remote_url(remote_url)
|
remote_url_masked = mask_git_remote_url(remote_url)
|
||||||
|
git_env = _git_env()
|
||||||
|
|
||||||
if not source_dir.exists():
|
cloned = not source_dir.exists()
|
||||||
# 首次:从远程 clone
|
if cloned:
|
||||||
if not remote_url:
|
if not remote_url:
|
||||||
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
|
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
|
||||||
source_dir.parent.mkdir(parents=True, exist_ok=True)
|
source_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
await log_streamer.emit(task_id, f"克隆仓库: {remote_url_masked} → {branch}")
|
await log_streamer.emit(task_id, f"初始化共享源码目录: {remote_url_masked}")
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"git", "clone", "--depth", "1", "-b", branch, "--single-branch", remote_url, str(source_dir),
|
*_GIT_NO_CREDENTIAL_HELPER, "clone", remote_url, str(source_dir),
|
||||||
|
env=git_env,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
@@ -157,25 +270,34 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
|
|||||||
await log_streamer.emit(task_id, decoded)
|
await log_streamer.emit(task_id, decoded)
|
||||||
await process.wait()
|
await process.wait()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise Exception(f"git clone 失败: {branch}")
|
raise Exception("git clone 失败")
|
||||||
else:
|
|
||||||
# 已存在:fetch + checkout + pull
|
|
||||||
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
|
|
||||||
|
|
||||||
if remote_url:
|
# git clone 成功即保证目录有效;已有目录则额外拦截误配置路径。
|
||||||
process = await asyncio.create_subprocess_exec(
|
if not cloned and not (source_dir / ".git").exists():
|
||||||
"git", "remote", "set-url", "origin", remote_url,
|
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
|
||||||
cwd=str(source_dir),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
await process.wait()
|
|
||||||
|
|
||||||
# fetch
|
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}")
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"git", "fetch", "origin",
|
*_GIT_NO_CREDENTIAL_HELPER, "remote", "set-url", "origin", remote_url,
|
||||||
cwd=str(source_dir),
|
env=git_env,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
cwd=str(source_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
await process.wait()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise Exception("git remote set-url 失败")
|
||||||
|
|
||||||
|
commands = [
|
||||||
|
(("git", "fetch", "origin"), "git fetch 失败"),
|
||||||
|
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
|
||||||
|
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
|
||||||
|
(("git", "clean", "-ffdx"), "git clean 失败"),
|
||||||
|
]
|
||||||
|
for command, error_message in commands:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*_GIT_NO_CREDENTIAL_HELPER, *command[1:], env=git_env,
|
||||||
|
cwd=str(source_dir), stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
async for line in process.stdout:
|
async for line in process.stdout:
|
||||||
@@ -184,41 +306,47 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
|
|||||||
await log_streamer.emit(task_id, decoded)
|
await log_streamer.emit(task_id, decoded)
|
||||||
await process.wait()
|
await process.wait()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise Exception("git fetch 失败")
|
raise Exception(error_message)
|
||||||
|
|
||||||
# checkout
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
"git", "checkout", "-B", branch, f"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 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}")
|
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_commit(source_dir: Path) -> str:
|
||||||
|
"""读取当前共享源码快照的 commit SHA。"""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"git", "rev-parse", "HEAD", cwd=str(source_dir),
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
output = await process.stdout.read()
|
||||||
|
await process.wait()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise Exception("读取源码 commit 失败")
|
||||||
|
return output.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
|
||||||
|
"""准备任务独立源码快照;远程仓库模式下此过程全局串行。"""
|
||||||
|
if not get_git_remote_url():
|
||||||
|
source_dir = get_source_dir(task.branch)
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise Exception(
|
||||||
|
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
|
||||||
|
)
|
||||||
|
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
||||||
|
return await copy_source_code(task_id, task, source_dir), ""
|
||||||
|
|
||||||
|
source_dir = get_shared_source_dir()
|
||||||
|
await log_streamer.emit(task_id, "等待共享源码准备队列...")
|
||||||
|
async with source_prepare_lock:
|
||||||
|
await log_streamer.emit(task_id, "开始准备共享源码快照")
|
||||||
|
await update_source(task_id, source_dir, task.branch)
|
||||||
|
commit = await get_source_commit(source_dir)
|
||||||
|
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
|
||||||
|
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||||
|
await log_streamer.emit(task_id, "共享源码快照已就绪,开始并行打包")
|
||||||
|
return build_dir, commit
|
||||||
|
|
||||||
|
|
||||||
async def run_build_task(task_id: str):
|
async def run_build_task(task_id: str):
|
||||||
"""执行打包任务"""
|
"""执行打包任务"""
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
@@ -251,25 +379,16 @@ async def run_build_task(task_id: str):
|
|||||||
|
|
||||||
async def _do_build():
|
async def _do_build():
|
||||||
nonlocal build_dir
|
nonlocal build_dir
|
||||||
# 1. 更新分支源码并拷贝
|
# 1. 串行准备源码快照;后续步骤在任务独立目录中并行执行。
|
||||||
source_dir = get_source_dir(task.branch)
|
await asyncio.to_thread(_db_update, db, task, current_step="source_prepare")
|
||||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
build_dir, source_commit = await prepare_source_snapshot(task_id, task)
|
||||||
if get_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:
|
|
||||||
raise Exception(
|
|
||||||
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
|
|
||||||
)
|
|
||||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
|
||||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||||
|
|
||||||
# 2. 生成配置
|
# 2. 生成配置
|
||||||
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
||||||
config_data = await generate_config(task_id, task, build_dir)
|
config_data = await generate_config(task_id, task, build_dir)
|
||||||
|
config_data["SOURCE_BRANCH"] = task.branch
|
||||||
|
config_data["SOURCE_COMMIT"] = source_commit
|
||||||
await asyncio.to_thread(_db_update, db, task,
|
await asyncio.to_thread(_db_update, db, task,
|
||||||
config_json=json.dumps(config_data, ensure_ascii=False))
|
config_json=json.dumps(config_data, ensure_ascii=False))
|
||||||
|
|
||||||
@@ -303,17 +422,18 @@ async def run_build_task(task_id: str):
|
|||||||
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
|
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
|
||||||
await asyncio.to_thread(_db_update, db, task, **update_fields)
|
await asyncio.to_thread(_db_update, db, task, **update_fields)
|
||||||
|
|
||||||
# 6. 上传分发(仅 Ad_Hoc)
|
# 6. 上传产物:Ad Hoc 发布安装页,App Store 上传 IPA 供下载提交。
|
||||||
if task.build_type == "Ad_Hoc":
|
await asyncio.to_thread(_db_update, db, task, current_step="upload")
|
||||||
await asyncio.to_thread(_db_update, db, task, current_step="upload")
|
oss_url, qr_code_path = await upload_ipa(
|
||||||
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path, build_dir)
|
task_id, task, config_data, ipa_path, build_dir
|
||||||
upload_fields = {}
|
)
|
||||||
if oss_url:
|
upload_fields = {}
|
||||||
upload_fields["oss_url"] = oss_url
|
if oss_url:
|
||||||
if qr_code_path:
|
upload_fields["oss_url"] = oss_url
|
||||||
upload_fields["qr_code_path"] = qr_code_path
|
if qr_code_path:
|
||||||
if upload_fields:
|
upload_fields["qr_code_path"] = qr_code_path
|
||||||
await asyncio.to_thread(_db_update, db, task, **upload_fields)
|
if upload_fields:
|
||||||
|
await asyncio.to_thread(_db_update, db, task, **upload_fields)
|
||||||
|
|
||||||
# 完成
|
# 完成
|
||||||
await asyncio.to_thread(_db_update, db, task,
|
await asyncio.to_thread(_db_update, db, task,
|
||||||
@@ -325,6 +445,23 @@ async def run_build_task(task_id: str):
|
|||||||
if task.oss_url:
|
if task.oss_url:
|
||||||
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
|
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
|
||||||
|
|
||||||
|
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。
|
||||||
|
if task.build_type == "Ad_Hoc":
|
||||||
|
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
|
||||||
|
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
|
||||||
|
try:
|
||||||
|
notified = await asyncio.to_thread(
|
||||||
|
send_dingtalk_notification,
|
||||||
|
dingtalk_config,
|
||||||
|
config_data,
|
||||||
|
task.oss_url or oss_url,
|
||||||
|
task.qr_code_path or qr_code_path,
|
||||||
|
)
|
||||||
|
if notified:
|
||||||
|
await log_streamer.emit(task_id, "钉钉通知发送成功")
|
||||||
|
except NotificationError as exc:
|
||||||
|
await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn")
|
||||||
|
|
||||||
# 带超时执行打包
|
# 带超时执行打包
|
||||||
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
|
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
|
||||||
|
|
||||||
@@ -398,6 +535,10 @@ async def copy_source_code(task_id: str, task, source_dir: Path) -> Path:
|
|||||||
|
|
||||||
for item in COPY_ITEMS:
|
for item in COPY_ITEMS:
|
||||||
src = source_dir / item
|
src = source_dir / item
|
||||||
|
# 历史分支中该文件可能仍是小写 podfile;构建副本统一使用 Pods.xcodeproj
|
||||||
|
# 引用的标准名称 Podfile,避免大小写敏感文件系统报路径不一致。
|
||||||
|
if item == "Podfile" and not src.is_file():
|
||||||
|
src = source_dir / "podfile"
|
||||||
dst = build_dir / item
|
dst = build_dir / item
|
||||||
if src.is_dir():
|
if src.is_dir():
|
||||||
await log_streamer.emit(task_id, f"拷贝目录: {item}")
|
await log_streamer.emit(task_id, f"拷贝目录: {item}")
|
||||||
@@ -502,46 +643,15 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
|||||||
else:
|
else:
|
||||||
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
|
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
|
||||||
|
|
||||||
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME
|
# 严格沿用 AutoPacking/start_build_app.py 的 configure_provisioning:
|
||||||
|
# 描述文件必须成功匹配,并从文件本身提取 Name 与 TeamIdentifier。
|
||||||
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
|
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
|
||||||
if provisioning_profile:
|
if provisioning_profile:
|
||||||
profile_path = provisioning_profile
|
profile_name, team_id = await asyncio.to_thread(
|
||||||
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
|
_resolve_provisioning_profile, provisioning_profile
|
||||||
if not os.path.exists(profile_path):
|
)
|
||||||
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
|
config_data["PROVISIONING_NAME"] = profile_name
|
||||||
if os.path.isdir(profiles_dir):
|
config_data["TEAM_ID"] = team_id
|
||||||
import subprocess as _sp
|
|
||||||
import plistlib as _pl
|
|
||||||
for filename in os.listdir(profiles_dir):
|
|
||||||
if not filename.endswith(".mobileprovision"):
|
|
||||||
continue
|
|
||||||
candidate = os.path.join(profiles_dir, filename)
|
|
||||||
try:
|
|
||||||
plist_xml = _sp.check_output(
|
|
||||||
["security", "cms", "-D", "-i", candidate],
|
|
||||||
stderr=_sp.DEVNULL,
|
|
||||||
)
|
|
||||||
plist = _pl.loads(plist_xml)
|
|
||||||
if plist.get("Name") == provisioning_profile:
|
|
||||||
profile_path = candidate
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if os.path.exists(profile_path):
|
|
||||||
try:
|
|
||||||
import subprocess as _sp
|
|
||||||
import plistlib as _pl
|
|
||||||
plist_xml = _sp.check_output(
|
|
||||||
["security", "cms", "-D", "-i", profile_path],
|
|
||||||
stderr=_sp.DEVNULL,
|
|
||||||
)
|
|
||||||
plist = _pl.loads(plist_xml)
|
|
||||||
team_ids = plist.get("TeamIdentifier", [])
|
|
||||||
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
|
|
||||||
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 关联域名等配置
|
# 关联域名等配置
|
||||||
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
|
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
|
||||||
@@ -593,6 +703,13 @@ async def run_pod_install(task_id: str, build_dir: Path):
|
|||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise BuildError("pod install 失败", category="config")
|
raise BuildError("pod install 失败", category="config")
|
||||||
|
|
||||||
|
patched_files = await asyncio.to_thread(_patch_afnetworking_private_headers, build_dir)
|
||||||
|
if patched_files:
|
||||||
|
await log_streamer.emit(
|
||||||
|
task_id,
|
||||||
|
f"已修复 AFNetworking 私有头引用: {patched_files} 个文件",
|
||||||
|
)
|
||||||
|
|
||||||
await log_streamer.emit(task_id, "依赖安装完成")
|
await log_streamer.emit(task_id, "依赖安装完成")
|
||||||
|
|
||||||
|
|
||||||
@@ -661,14 +778,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
|||||||
|
|
||||||
# 清理
|
# 清理
|
||||||
await log_streamer.emit(task_id, "清理项目...")
|
await log_streamer.emit(task_id, "清理项目...")
|
||||||
clean_cmd = (
|
process = await asyncio.create_subprocess_exec(
|
||||||
f"xcodebuild clean -workspace {workspace_path.name} "
|
"xcodebuild", "clean",
|
||||||
f"-scheme {scheme} "
|
"-workspace", workspace_path.name,
|
||||||
f"-configuration Release "
|
"-scheme", scheme,
|
||||||
f"-derivedDataPath {export_path / 'derived_data'}"
|
"-configuration", "Release",
|
||||||
)
|
"-derivedDataPath", str(export_path / "derived_data"),
|
||||||
process = await asyncio.create_subprocess_shell(
|
|
||||||
clean_cmd,
|
|
||||||
cwd=str(build_dir),
|
cwd=str(build_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
@@ -681,17 +796,15 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
|||||||
|
|
||||||
# Archive
|
# Archive
|
||||||
await log_streamer.emit(task_id, "开始 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"
|
|
||||||
)
|
|
||||||
archive_output = []
|
archive_output = []
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_exec(
|
||||||
archive_cmd,
|
"xcodebuild", "archive",
|
||||||
|
"-workspace", workspace_path.name,
|
||||||
|
"-scheme", scheme,
|
||||||
|
"-configuration", "Release",
|
||||||
|
"-archivePath", str(archive_path),
|
||||||
|
"-derivedDataPath", str(export_path / "derived_data"),
|
||||||
|
"-destination", "generic/platform=ios",
|
||||||
cwd=str(build_dir),
|
cwd=str(build_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
@@ -710,15 +823,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
|||||||
# 导出 IPA
|
# 导出 IPA
|
||||||
await log_streamer.emit(task_id, "导出 IPA...")
|
await log_streamer.emit(task_id, "导出 IPA...")
|
||||||
export_plist = build_dir / "exportOptions.plist"
|
export_plist = build_dir / "exportOptions.plist"
|
||||||
export_cmd = (
|
|
||||||
f"xcodebuild -exportArchive "
|
|
||||||
f"-archivePath {archive_path} "
|
|
||||||
f"-exportPath {export_path} "
|
|
||||||
f"-exportOptionsPlist {export_plist}"
|
|
||||||
)
|
|
||||||
export_output = []
|
export_output = []
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_exec(
|
||||||
export_cmd,
|
"xcodebuild", "-exportArchive",
|
||||||
|
"-archivePath", str(archive_path),
|
||||||
|
"-exportPath", str(export_path),
|
||||||
|
"-exportOptionsPlist", str(export_plist),
|
||||||
cwd=str(build_dir),
|
cwd=str(build_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""服务端 Ad_Hoc 分发:OSS/WebDAV 上传、manifest、下载页和二维码。"""
|
"""服务端 IPA 分发:App Store IPA 直传及 Ad Hoc 安装页发布。"""
|
||||||
import json
|
import json
|
||||||
import plistlib
|
import plistlib
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, unquote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -15,7 +16,12 @@ class DistributionError(Exception):
|
|||||||
|
|
||||||
def _artifact_stem(config: dict) -> str:
|
def _artifact_stem(config: dict) -> str:
|
||||||
version = config.get("VERSION", "0").replace(".", "_")
|
version = config.get("VERSION", "0").replace(".", "_")
|
||||||
return f"{config.get('APPID', 'app')}_{version}"
|
# 历史任务快照没有 SOURCE_BRANCH,继续按旧文件名处理,确保删除旧记录时能清理到原产物。
|
||||||
|
if "SOURCE_BRANCH" not in config:
|
||||||
|
return f"{config.get('APPID', 'app')}_{version}"
|
||||||
|
branch = re.sub(r"[^A-Za-z0-9._-]+", "_", config.get("SOURCE_BRANCH", "main")).strip("._-")
|
||||||
|
build_type = "appstore" if config.get("BUILD_TYPE") == "App_Store" else "adhoc"
|
||||||
|
return f"{config.get('APPID', 'app')}_{version}_{branch or 'main'}_{build_type}"
|
||||||
|
|
||||||
|
|
||||||
def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
|
def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
|
||||||
@@ -58,7 +64,7 @@ def _write_download_page(config: dict, html: Path, manifest_url: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _remote_paths(config: dict) -> tuple[str, str, str]:
|
def _remote_paths(config: dict) -> tuple[str, str, str, str]:
|
||||||
stem = _artifact_stem(config)
|
stem = _artifact_stem(config)
|
||||||
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
||||||
root = posixpath.join(folder, "iOS")
|
root = posixpath.join(folder, "iOS")
|
||||||
@@ -66,9 +72,101 @@ def _remote_paths(config: dict) -> tuple[str, str, str]:
|
|||||||
posixpath.join(root, f"{stem}.ipa"),
|
posixpath.join(root, f"{stem}.ipa"),
|
||||||
posixpath.join(root, f"{stem}.plist"),
|
posixpath.join(root, f"{stem}.plist"),
|
||||||
posixpath.join(root, f"{stem}.html"),
|
posixpath.join(root, f"{stem}.html"),
|
||||||
|
posixpath.join(root, f"{stem}.png"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_webdav(config: dict, remote_paths: list[str]):
|
||||||
|
webdav = config.get("webdav", {})
|
||||||
|
server_url = webdav.get("server_url", "").rstrip("/")
|
||||||
|
if not server_url or not webdav.get("username"):
|
||||||
|
raise DistributionError("WebDAV 配置不完整")
|
||||||
|
|
||||||
|
base_path = webdav.get("base_path", "/ios-builds").strip("/")
|
||||||
|
auth = (webdav.get("username", ""), webdav.get("password", ""))
|
||||||
|
with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client:
|
||||||
|
for relative_path in remote_paths:
|
||||||
|
remote_path = posixpath.join(base_path, relative_path)
|
||||||
|
response = client.delete(f"{server_url}/{remote_path}")
|
||||||
|
# 404 代表文件已不存在,可视为清理完成。
|
||||||
|
if response.status_code not in (200, 202, 204, 404):
|
||||||
|
raise DistributionError(f"WebDAV 删除失败: {relative_path} ({response.status_code})")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_oss(config: dict, remote_paths: list[str]):
|
||||||
|
oss = config.get("oss", {})
|
||||||
|
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
|
||||||
|
if any(not oss.get(key) for key in required):
|
||||||
|
raise DistributionError("OSS 配置不完整")
|
||||||
|
try:
|
||||||
|
import oss2
|
||||||
|
except ImportError as exc:
|
||||||
|
raise DistributionError("未安装 oss2,请重新执行 ./deploy.sh build") from exc
|
||||||
|
|
||||||
|
bucket = oss2.Bucket(
|
||||||
|
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
|
||||||
|
oss["endpoint"],
|
||||||
|
oss["bucket_name"],
|
||||||
|
connect_timeout=30,
|
||||||
|
)
|
||||||
|
for relative_path in remote_paths:
|
||||||
|
result = bucket.delete_object(relative_path)
|
||||||
|
if result.status // 100 != 2:
|
||||||
|
raise DistributionError(f"OSS 删除失败: {relative_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_paths_from_published_url(
|
||||||
|
build_config: dict, upload_config: dict, published_url: str,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""从已保存的公开链接还原实际对象键,兼容历史命名规则。"""
|
||||||
|
parsed = urlparse(published_url)
|
||||||
|
if not parsed.path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
remote_path = unquote(parsed.path).lstrip("/")
|
||||||
|
# base_url 允许带路径前缀;该前缀是公开地址的一部分,不属于 OSS 对象键。
|
||||||
|
base_url = upload_config.get("oss", {}).get("base_url", "")
|
||||||
|
base = urlparse(base_url)
|
||||||
|
if base_url and base.netloc == parsed.netloc:
|
||||||
|
base_path = unquote(base.path).strip("/")
|
||||||
|
if base_path and remote_path.startswith(f"{base_path}/"):
|
||||||
|
remote_path = remote_path[len(base_path) + 1:]
|
||||||
|
|
||||||
|
path = Path(remote_path)
|
||||||
|
if path.suffix not in {".ipa", ".plist", ".html", ".png"}:
|
||||||
|
return None
|
||||||
|
if build_config.get("BUILD_TYPE") == "App_Store":
|
||||||
|
return [str(path.with_suffix(".ipa"))]
|
||||||
|
return [str(path.with_suffix(suffix)) for suffix in (".ipa", ".plist", ".html", ".png")]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_published_artifacts(
|
||||||
|
build_config: dict, upload_config: dict, published_url: str = "",
|
||||||
|
):
|
||||||
|
"""删除任务对应的远端分发产物。
|
||||||
|
|
||||||
|
优先按任务保存的下载链接还原对象键。这样即使后续升级了文件命名
|
||||||
|
规则,或历史快照含有新的分支字段,仍会删除当时实际上传的文件。
|
||||||
|
"""
|
||||||
|
mode = upload_config.get("mode", "")
|
||||||
|
if mode not in {"oss", "webdav"}:
|
||||||
|
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
|
||||||
|
|
||||||
|
remote_paths = None
|
||||||
|
if mode == "oss" and published_url:
|
||||||
|
remote_paths = _remote_paths_from_published_url(
|
||||||
|
build_config, upload_config, published_url,
|
||||||
|
)
|
||||||
|
if not remote_paths:
|
||||||
|
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(build_config)
|
||||||
|
remote_paths = [ipa_remote]
|
||||||
|
if build_config.get("BUILD_TYPE") != "App_Store":
|
||||||
|
remote_paths.extend([manifest_remote, html_remote, qr_remote])
|
||||||
|
|
||||||
|
deleter = _delete_oss if mode == "oss" else _delete_webdav
|
||||||
|
deleter(upload_config, remote_paths)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
||||||
path = ""
|
path = ""
|
||||||
for segment in remote_path.strip("/").split("/")[:-1]:
|
for segment in remote_path.strip("/").split("/")[:-1]:
|
||||||
@@ -127,7 +225,7 @@ def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
||||||
"""发布 IPA,返回下载页 URL 与二维码本地路径。"""
|
"""发布 IPA,返回下载 URL 与二维码公开 URL。"""
|
||||||
upload = config.get("_upload_config", {})
|
upload = config.get("_upload_config", {})
|
||||||
mode = upload.get("mode", "")
|
mode = upload.get("mode", "")
|
||||||
if mode not in {"oss", "webdav"}:
|
if mode not in {"oss", "webdav"}:
|
||||||
@@ -135,10 +233,16 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
|
|||||||
|
|
||||||
output_dir = build_dir / "distribution"
|
output_dir = build_dir / "distribution"
|
||||||
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
||||||
ipa_remote, manifest_remote, html_remote = _remote_paths(config)
|
ipa_remote, manifest_remote, html_remote, qr_remote = _remote_paths(config)
|
||||||
|
|
||||||
# manifest 依赖 IPA URL,先发布 IPA。
|
|
||||||
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
||||||
|
|
||||||
|
# App Store 包只需要提供 IPA 下载地址,不能生成 itms-services 安装页。
|
||||||
|
if config.get("BUILD_TYPE") == "App_Store":
|
||||||
|
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
||||||
|
return urls[".ipa"], ""
|
||||||
|
|
||||||
|
# Ad Hoc manifest 依赖 IPA URL,先发布 IPA。
|
||||||
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
||||||
_write_manifest(config, manifest, urls[".ipa"])
|
_write_manifest(config, manifest, urls[".ipa"])
|
||||||
urls.update(uploader(upload, [(manifest, manifest_remote)]))
|
urls.update(uploader(upload, [(manifest, manifest_remote)]))
|
||||||
@@ -151,4 +255,5 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
|
|||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
|
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
|
||||||
qrcode.make(urls[".html"]).save(qr_path)
|
qrcode.make(urls[".html"]).save(qr_path)
|
||||||
return urls[".html"], str(qr_path)
|
urls.update(uploader(upload, [(qr_path, qr_remote)]))
|
||||||
|
return urls[".html"], urls[".png"]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""打包完成后的钉钉通知。"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationError(Exception):
|
||||||
|
"""通知发送失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_webhook_url(webhook_url: str, secret: str) -> str:
|
||||||
|
"""为启用了加签的钉钉机器人附加 timestamp 与 sign 参数。"""
|
||||||
|
if not secret:
|
||||||
|
return webhook_url
|
||||||
|
|
||||||
|
timestamp = str(round(time.time() * 1000))
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode("utf-8"),
|
||||||
|
f"{timestamp}\n{secret}".encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
separator = "&" if "?" in webhook_url else "?"
|
||||||
|
return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: str = "") -> dict:
|
||||||
|
"""生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。"""
|
||||||
|
details = (
|
||||||
|
f"**环境:** {config_data.get('SERVER', '')}\n\n"
|
||||||
|
f"**版本:** {config_data.get('VERSION', '')}\n\n"
|
||||||
|
f"**APP名称:** {config_data.get('APPID_NAME', '')}\n\n"
|
||||||
|
f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n"
|
||||||
|
f"**App Guid:** {config_data.get('APPID', '')}"
|
||||||
|
)
|
||||||
|
text = f"## 【iOS】打包信息\n\n{details}\n\n**iOS 下载链接:** {download_url}\n"
|
||||||
|
if qr_code_url:
|
||||||
|
text += f"\n"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {"title": "iOS应用下载", "text": text},
|
||||||
|
"at": {"atMobiles": [], "isAtAll": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_dingtalk_notification(
|
||||||
|
dingtalk_config: dict,
|
||||||
|
config_data: dict,
|
||||||
|
download_url: str,
|
||||||
|
qr_code_url: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""发送钉钉通知;未启用通知时不发起网络请求。"""
|
||||||
|
if not dingtalk_config.get("enabled"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
webhook_url = dingtalk_config.get("webhook_url", "").strip()
|
||||||
|
if not webhook_url:
|
||||||
|
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
|
||||||
|
|
||||||
|
payload = build_dingtalk_payload(config_data, download_url, qr_code_url)
|
||||||
|
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", [])
|
||||||
|
response = httpx.post(
|
||||||
|
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
|
||||||
|
json=payload,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if response.status_code // 100 != 2:
|
||||||
|
raise NotificationError(f"钉钉通知发送失败 ({response.status_code})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise NotificationError("钉钉通知返回内容无效") from exc
|
||||||
|
if result.get("errcode", 0) != 0:
|
||||||
|
raise NotificationError(f"钉钉通知发送失败: {result.get('errmsg', '未知错误')}")
|
||||||
|
return True
|
||||||
@@ -163,6 +163,14 @@ def apply_project_config(build_dir: Path, config: dict) -> list[str]:
|
|||||||
(project, '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" =', f"\t\t\t\t\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\" = {config['PROVISIONING_NAME']};"),
|
(project, '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" =', f"\t\t\t\t\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\" = {config['PROVISIONING_NAME']};"),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# 与 AutoPacking/replace_build_info.py 保持一致:工程内所有构建配置都使用
|
||||||
|
# Distribution identity。只更新 exportOptions.plist 不足以覆盖 Archive 阶段,
|
||||||
|
# 否则 pbxproj 中残留的 iPhone/iOS Developer 会让 Xcode 查找开发证书。
|
||||||
|
replacements.extend([
|
||||||
|
(project, "CODE_SIGN_IDENTITY =", '\t\t\t\tCODE_SIGN_IDENTITY = "iPhone Distribution";'),
|
||||||
|
(project, '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" =', '\t\t\t\t"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";'),
|
||||||
|
])
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
for path, keyword, replacement in replacements:
|
for path, keyword, replacement in replacements:
|
||||||
if _replace_lines(path, keyword, replacement) == 0:
|
if _replace_lines(path, keyword, replacement) == 0:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ if [ -f .env ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||||
|
BIND_HOST="${BIND_HOST:-127.0.0.1}"
|
||||||
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
|
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
|
||||||
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
||||||
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
||||||
@@ -159,10 +160,12 @@ do_start() {
|
|||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
info "启动后端服务 (端口: $BACKEND_PORT)..."
|
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
|
||||||
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
||||||
--host 0.0.0.0 \
|
--host "$BIND_HOST" \
|
||||||
--port "$BACKEND_PORT" \
|
--port "$BACKEND_PORT" \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips 127.0.0.1 \
|
||||||
--workers 1 \
|
--workers 1 \
|
||||||
> "$LOG_DIR/server.log" 2>&1 &
|
> "$LOG_DIR/server.log" 2>&1 &
|
||||||
|
|
||||||
@@ -170,11 +173,8 @@ do_start() {
|
|||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||||
local access_host
|
|
||||||
access_host=$(ipconfig getifaddr en0 2>/dev/null || true)
|
|
||||||
access_host="${access_host:-localhost}"
|
|
||||||
info "服务已启动 (PID: $(cat "$PID_FILE"))"
|
info "服务已启动 (PID: $(cat "$PID_FILE"))"
|
||||||
info "访问地址: http://$access_host:$BACKEND_PORT"
|
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
|
||||||
else
|
else
|
||||||
error "启动失败,请查看日志: $LOG_DIR/server.log"
|
error "启动失败,请查看日志: $LOG_DIR/server.log"
|
||||||
rm -f "$PID_FILE"
|
rm -f "$PID_FILE"
|
||||||
@@ -382,8 +382,10 @@ do_watchdog_loop() {
|
|||||||
setup_venv
|
setup_venv
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
||||||
--host 0.0.0.0 \
|
--host "$BIND_HOST" \
|
||||||
--port "$BACKEND_PORT" \
|
--port "$BACKEND_PORT" \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips 127.0.0.1 \
|
||||||
--workers 1 \
|
--workers 1 \
|
||||||
>> "$LOG_DIR/server.log" 2>&1 &
|
>> "$LOG_DIR/server.log" 2>&1 &
|
||||||
echo $! > "$PID_FILE"
|
echo $! > "$PID_FILE"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 将 build.example.com、证书路径和端口替换为实际值。
|
||||||
|
limit_req_zone $binary_remote_addr zone=build_login:10m rate=5r/m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name build.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name build.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/build.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/build.example.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location = /api/auth/login {
|
||||||
|
limit_req zone=build_login burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<nav v-if="isLoggedIn" class="nav">
|
<nav v-if="isLoggedIn" class="nav">
|
||||||
<router-link to="/build">打包</router-link>
|
<router-link to="/build">打包</router-link>
|
||||||
<router-link to="/history">历史</router-link>
|
<router-link to="/history">历史</router-link>
|
||||||
<router-link v-if="isAdmin" to="/admin">管理</router-link>
|
<router-link to="/admin">管理</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-if="!isLoggedIn" class="user-info">
|
<div v-if="!isLoggedIn" class="user-info">
|
||||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||||
@@ -45,7 +45,6 @@
|
|||||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="login">
|
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="login">
|
||||||
</div>
|
</div>
|
||||||
<button class="login-btn" @click="login">登录</button>
|
<button class="login-btn" @click="login">登录</button>
|
||||||
<div class="login-hint">默认账号: admin / admin123</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,5 +152,4 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
|
|||||||
.form-group input:focus { outline: none; border-color: #1890ff; }
|
.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 { 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-btn:hover { background: #40a9ff; }
|
||||||
.login-hint { text-align: center; margin-top: 16px; font-size: 12px; color: #999; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ describe('App.vue', () => {
|
|||||||
expect(wrapper.find('.login-modal').exists()).toBe(true)
|
expect(wrapper.find('.login-modal').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('登录框不展示默认管理员账号密码', async () => {
|
||||||
|
const router = createMockRouter()
|
||||||
|
const wrapper = mount(App, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await wrapper.find('.btn-login').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('.login-modal').text()).not.toContain('默认账号')
|
||||||
|
expect(wrapper.find('.login-modal').text()).not.toContain('admin123')
|
||||||
|
})
|
||||||
|
|
||||||
it('登录成功后更新状态', async () => {
|
it('登录成功后更新状态', async () => {
|
||||||
fetch.mockResolvedValueOnce({
|
fetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import BuildView from '../views/BuildView.vue'
|
import BuildView from '../views/BuildView.vue'
|
||||||
|
|
||||||
global.fetch = vi.fn()
|
global.fetch = vi.fn()
|
||||||
@@ -11,12 +12,25 @@ class MockWebSocket {
|
|||||||
}
|
}
|
||||||
global.WebSocket = MockWebSocket
|
global.WebSocket = MockWebSocket
|
||||||
|
|
||||||
|
const mountBuildView = () => {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/', component: BuildView }],
|
||||||
|
})
|
||||||
|
return mount(BuildView, {
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
provide: { getToken: () => '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('BuildView.vue', () => {
|
describe('BuildView.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
fetch.mockImplementation((url) => {
|
fetch.mockImplementation((url) => {
|
||||||
const responses = {
|
const responses = {
|
||||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
|
||||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||||
'/api/branches': ['main', 'dev'],
|
'/api/branches': ['main', 'dev'],
|
||||||
'/api/tasks': [],
|
'/api/tasks': [],
|
||||||
@@ -28,7 +42,7 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('显示打包配置面板', async () => {
|
it('显示打包配置面板', async () => {
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('打包配置')
|
expect(wrapper.text()).toContain('打包配置')
|
||||||
@@ -39,15 +53,17 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('加载 apps 和 schemes', async () => {
|
it('加载 apps 和 schemes', async () => {
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const appOptions = wrapper.findAll('select')[0].findAll('option')
|
wrapper.vm.selectedServer = '测试环境'
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
const appOptions = wrapper.findAll('select')[1].findAll('option')
|
||||||
expect(appOptions.length).toBeGreaterThan(1)
|
expect(appOptions.length).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('加载分支列表并显示下拉', async () => {
|
it('加载分支列表并显示下拉', async () => {
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const branchSelect = wrapper.findAll('select').find(s => {
|
const branchSelect = wrapper.findAll('select').find(s => {
|
||||||
@@ -58,10 +74,10 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('默认值正确', async () => {
|
it('默认值正确', async () => {
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.vm.form.build_type).toBe('Ad_Hoc')
|
expect(wrapper.vm.form.build_type).toBe('')
|
||||||
expect(wrapper.vm.form.obfuscation).toBe(false)
|
expect(wrapper.vm.form.obfuscation).toBe(false)
|
||||||
expect(wrapper.vm.form.branch).toBe('main')
|
expect(wrapper.vm.form.branch).toBe('main')
|
||||||
})
|
})
|
||||||
@@ -75,7 +91,7 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const responses = {
|
const responses = {
|
||||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
|
||||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||||
'/api/branches': ['main', 'dev'],
|
'/api/branches': ['main', 'dev'],
|
||||||
'/api/tasks': [],
|
'/api/tasks': [],
|
||||||
@@ -85,21 +101,28 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await wrapper.setData({ form: { ...wrapper.vm.form, app_id: '1' } })
|
wrapper.vm.selectedServer = '测试环境'
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
wrapper.vm.form.app_id = '1'
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
await wrapper.find('.btn-primary').trigger('click')
|
await wrapper.find('.btn-primary').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
|
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}))
|
}))
|
||||||
|
expect(wrapper.text()).toContain('打包任务已创建,正在排队,请勿重复点击。')
|
||||||
|
expect(wrapper.vm.form.app_id).toBe('')
|
||||||
|
expect(wrapper.vm.form.build_type).toBe('')
|
||||||
|
expect(wrapper.vm.form.scheme_id).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未选择 App 时提示', async () => {
|
it('未选择 App 时提示', async () => {
|
||||||
window.alert = vi.fn()
|
window.alert = vi.fn()
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await wrapper.find('.btn-primary').trigger('click')
|
await wrapper.find('.btn-primary').trigger('click')
|
||||||
@@ -109,7 +132,7 @@ describe('BuildView.vue', () => {
|
|||||||
it('显示任务队列', async () => {
|
it('显示任务队列', async () => {
|
||||||
fetch.mockImplementation((url) => {
|
fetch.mockImplementation((url) => {
|
||||||
const responses = {
|
const responses = {
|
||||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
|
||||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||||
'/api/branches': ['main', 'dev'],
|
'/api/branches': ['main', 'dev'],
|
||||||
'/api/tasks': [
|
'/api/tasks': [
|
||||||
@@ -122,7 +145,7 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const taskItems = wrapper.findAll('.task-item')
|
const taskItems = wrapper.findAll('.task-item')
|
||||||
@@ -130,7 +153,7 @@ describe('BuildView.vue', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('状态文本正确', async () => {
|
it('状态文本正确', async () => {
|
||||||
const wrapper = mount(BuildView)
|
const wrapper = mountBuildView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import BuildView from '../views/BuildView.vue'
|
||||||
|
|
||||||
|
const createTestRouter = () => createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/build', component: BuildView }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountBuildView = async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/build')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
const responses = {
|
||||||
|
'/api/apps': {
|
||||||
|
'1': { name: '测试阅读', server: '测试环境' },
|
||||||
|
'2': { name: '正式阅读', server: '正式环境' },
|
||||||
|
'3': { name: '测试词典', server: '测试环境' },
|
||||||
|
},
|
||||||
|
'/api/schemes': { '1': { name: 'readoor31' } },
|
||||||
|
'/api/branches': ['main'],
|
||||||
|
'/api/tasks': [],
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(responses[url] || {}) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(BuildView, {
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
provide: { getToken: () => '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuildView server environment filter', () => {
|
||||||
|
it('filters Apps by the selected server environment', async () => {
|
||||||
|
const wrapper = await mountBuildView()
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
const serverSelect = selects[0]
|
||||||
|
const appSelect = selects[1]
|
||||||
|
|
||||||
|
expect(appSelect.attributes('disabled')).toBeDefined()
|
||||||
|
await serverSelect.setValue('测试环境')
|
||||||
|
|
||||||
|
expect(appSelect.text()).toContain('测试阅读')
|
||||||
|
expect(appSelect.text()).toContain('测试词典')
|
||||||
|
expect(appSelect.text()).not.toContain('正式阅读')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the selected App when switching environments', async () => {
|
||||||
|
const wrapper = await mountBuildView()
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
const serverSelect = selects[0]
|
||||||
|
const appSelect = selects[1]
|
||||||
|
|
||||||
|
await serverSelect.setValue('测试环境')
|
||||||
|
await appSelect.setValue('1')
|
||||||
|
expect(wrapper.vm.form.app_id).toBe('1')
|
||||||
|
|
||||||
|
await serverSelect.setValue('正式环境')
|
||||||
|
expect(wrapper.vm.form.app_id).toBe('')
|
||||||
|
expect(appSelect.text()).toContain('正式阅读')
|
||||||
|
expect(appSelect.text()).not.toContain('测试阅读')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only offers configured build types and special App Schemes', async () => {
|
||||||
|
const wrapper = await mountBuildView()
|
||||||
|
await wrapper.setData({
|
||||||
|
apps: {
|
||||||
|
'1': {
|
||||||
|
name: '英汉大词典',
|
||||||
|
server: '测试环境',
|
||||||
|
certificates: { App_Store: { name: 'com.dictionary.app' } },
|
||||||
|
allowed_scheme_names: ['readoorDict'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemes: {
|
||||||
|
'1': { name: 'readoor31' },
|
||||||
|
'2': { name: 'readoorDict' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
await selects[0].setValue('测试环境')
|
||||||
|
await selects[1].setValue('1')
|
||||||
|
|
||||||
|
expect(selects[2].text()).toContain('App_Store')
|
||||||
|
expect(selects[2].text()).not.toContain('Ad_Hoc')
|
||||||
|
expect(selects[3].text()).toContain('readoorDict')
|
||||||
|
expect(selects[3].text()).not.toContain('readoor31')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { ref } from 'vue'
|
||||||
import ConfigView from '../views/ConfigView.vue'
|
import ConfigView from '../views/ConfigView.vue'
|
||||||
|
|
||||||
global.fetch = vi.fn()
|
global.fetch = vi.fn()
|
||||||
@@ -23,6 +24,7 @@ function mockFetch(url) {
|
|||||||
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
||||||
'/api/config/branches': ['main', 'dev'],
|
'/api/config/branches': ['main', 'dev'],
|
||||||
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
||||||
|
'/api/config/versions': { app_ver: '2.195.0', build_ver: '2.195.0.0' },
|
||||||
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
||||||
}
|
}
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -30,6 +32,17 @@ function mockFetch(url) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mountConfigView = ({ loggedIn = true, admin = true } = {}) => mount(ConfigView, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
showLogin: ref(false),
|
||||||
|
getToken: () => '',
|
||||||
|
isLoggedIn: ref(loggedIn),
|
||||||
|
isAdmin: ref(admin),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
describe('ConfigView.vue', () => {
|
describe('ConfigView.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -39,21 +52,19 @@ describe('ConfigView.vue', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('未登录时显示权限提示', async () => {
|
it('未登录时显示权限提示', async () => {
|
||||||
const wrapper = mount(ConfigView)
|
const wrapper = mountConfigView({ loggedIn: false })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('需要管理员权限')
|
expect(wrapper.text()).toContain('请先登录')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('已登录时显示管理页面', async () => {
|
it('已登录时显示管理页面', async () => {
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('配置管理')
|
expect(wrapper.text()).toContain('配置管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('显示侧边栏菜单', async () => {
|
it('显示侧边栏菜单', async () => {
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const menuItems = wrapper.findAll('.sidebar-menu li')
|
const menuItems = wrapper.findAll('.sidebar-menu li')
|
||||||
@@ -65,16 +76,14 @@ describe('ConfigView.vue', () => {
|
|||||||
expect(texts).toContain('打包设置')
|
expect(texts).toContain('打包设置')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('默认显示服务器环境 tab', async () => {
|
it('默认显示 Apps 配置 tab', async () => {
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('服务器环境配置')
|
expect(wrapper.text()).toContain('Apps 配置')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换到分支管理 tab', async () => {
|
it('切换到分支管理 tab', async () => {
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||||
@@ -93,8 +102,7 @@ describe('ConfigView.vue', () => {
|
|||||||
return mockFetch(url)
|
return mockFetch(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||||
@@ -122,8 +130,7 @@ describe('ConfigView.vue', () => {
|
|||||||
return mockFetch(url)
|
return mockFetch(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||||
@@ -147,8 +154,7 @@ describe('ConfigView.vue', () => {
|
|||||||
})
|
})
|
||||||
window.alert = vi.fn()
|
window.alert = vi.fn()
|
||||||
|
|
||||||
localStorageMock.getItem.mockReturnValue('true')
|
const wrapper = mountConfigView()
|
||||||
const wrapper = mount(ConfigView)
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||||
@@ -161,4 +167,20 @@ describe('ConfigView.vue', () => {
|
|||||||
|
|
||||||
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('普通账号可修改版本号但看不到打包参数', async () => {
|
||||||
|
const wrapper = mountConfigView({ admin: false })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
|
||||||
|
expect(menuTexts).toEqual(['Apps 配置', '打包设置'])
|
||||||
|
|
||||||
|
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||||
|
await buildMenu.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('应用版本号')
|
||||||
|
expect(wrapper.text()).not.toContain('打包参数')
|
||||||
|
expect(wrapper.findAll('input').length).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
import HistoryView from '../views/HistoryView.vue'
|
import HistoryView from '../views/HistoryView.vue'
|
||||||
@@ -7,9 +8,9 @@ global.fetch = vi.fn()
|
|||||||
window.open = vi.fn()
|
window.open = vi.fn()
|
||||||
|
|
||||||
const mockTasks = [
|
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: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'readoor31OtherPayLongSchemeName', status: 'completed', created_at: '2024-01-01T10:00:00', config_json: '{"VERSION":"2.196.0"}', has_log: true, 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: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', config_json: '{"VERSION":"2.195.0"}', error_message: '构建失败', error_category: 'compilation' },
|
||||||
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' },
|
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00', config_json: '{"VERSION":"2.196.0"}' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function createMockRouter() {
|
function createMockRouter() {
|
||||||
@@ -22,6 +23,15 @@ function createMockRouter() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mountHistory(admin = false) {
|
||||||
|
return mount(HistoryView, {
|
||||||
|
global: {
|
||||||
|
plugins: [createMockRouter()],
|
||||||
|
provide: { isAdmin: ref(admin) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('HistoryView.vue', () => {
|
describe('HistoryView.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -47,7 +57,7 @@ describe('HistoryView.vue', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const rows = wrapper.findAll('tbody tr')
|
const rows = wrapper.findAll('tbody tr')
|
||||||
expect(rows.length).toBe(2) // App2(failed) 默认隐藏
|
expect(rows.length).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('显示任务信息', async () => {
|
it('显示任务信息', async () => {
|
||||||
@@ -58,27 +68,27 @@ describe('HistoryView.vue', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('App1')
|
expect(wrapper.text()).toContain('App1')
|
||||||
// App2 是 failed 状态,默认隐藏
|
expect(wrapper.text()).toContain('App2')
|
||||||
expect(wrapper.text()).not.toContain('App2')
|
|
||||||
expect(wrapper.text()).toContain('Ad_Hoc')
|
expect(wrapper.text()).toContain('Ad_Hoc')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('默认隐藏失败和已取消的任务', async () => {
|
it('默认显示失败任务及完整失败原因', async () => {
|
||||||
const router = createMockRouter()
|
const router = createMockRouter()
|
||||||
const wrapper = mount(HistoryView, {
|
const wrapper = mount(HistoryView, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router] },
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// 只显示 App1(completed)和 App3(pending),App2(failed)被隐藏
|
|
||||||
const rows = wrapper.findAll('tbody tr')
|
const rows = wrapper.findAll('tbody tr')
|
||||||
expect(rows.length).toBe(2)
|
expect(rows.length).toBe(3)
|
||||||
expect(wrapper.text()).toContain('App1')
|
expect(wrapper.text()).toContain('App1')
|
||||||
expect(wrapper.text()).toContain('App3')
|
expect(wrapper.text()).toContain('App3')
|
||||||
|
expect(wrapper.text()).toContain('失败')
|
||||||
|
expect(wrapper.text()).toContain('编译错误')
|
||||||
expect(wrapper.text()).toContain('等待中')
|
expect(wrapper.text()).toContain('等待中')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('按打包类型过滤', async () => {
|
it('版本号筛选为下拉选项', async () => {
|
||||||
const router = createMockRouter()
|
const router = createMockRouter()
|
||||||
const wrapper = mount(HistoryView, {
|
const wrapper = mount(HistoryView, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router] },
|
||||||
@@ -86,7 +96,9 @@ describe('HistoryView.vue', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const selects = wrapper.findAll('.filter-select')
|
const selects = wrapper.findAll('.filter-select')
|
||||||
await selects[0].setValue('Ad_Hoc')
|
expect(selects).toHaveLength(3)
|
||||||
|
expect(wrapper.text()).not.toContain('全部类型')
|
||||||
|
await selects[1].setValue('2.196.0')
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
const rows = wrapper.findAll('tbody tr')
|
const rows = wrapper.findAll('tbody tr')
|
||||||
@@ -101,27 +113,50 @@ describe('HistoryView.vue', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const selects = wrapper.findAll('.filter-select')
|
const selects = wrapper.findAll('.filter-select')
|
||||||
await selects[1].setValue('failed')
|
await selects[2].setValue('failed')
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
const rows = wrapper.findAll('tbody tr')
|
const rows = wrapper.findAll('tbody tr')
|
||||||
expect(rows.length).toBe(1) // App2
|
expect(rows.length).toBe(1) // App2
|
||||||
})
|
})
|
||||||
|
|
||||||
it('已完成任务显示下载按钮', async () => {
|
it('按 App 名称和版本号下拉选项筛选', async () => {
|
||||||
const router = createMockRouter()
|
const router = createMockRouter()
|
||||||
const wrapper = mount(HistoryView, {
|
const wrapper = mount(HistoryView, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router] },
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
const selects = wrapper.findAll('.filter-select')
|
||||||
|
await selects[0].setValue('App1')
|
||||||
|
await wrapper.find('.version-filter').setValue('2.196.0')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const rows = wrapper.findAll('tbody tr')
|
||||||
|
expect(rows.length).toBe(1)
|
||||||
|
expect(rows[0].text()).toContain('App1')
|
||||||
|
expect(rows[0].text()).toContain('2.196.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成任务显示下载按钮', async () => {
|
||||||
|
const wrapper = mountHistory(true)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
// 第一行(App1, completed)应该有 dSYM 和下载按钮
|
// 第一行(App1, completed)应该有 dSYM 和下载按钮
|
||||||
const firstRow = wrapper.findAll('tbody tr')[0]
|
const firstRow = wrapper.findAll('tbody tr')[0]
|
||||||
const buttons = firstRow.findAll('.action-btn')
|
const buttons = firstRow.findAll('.action-btn')
|
||||||
const buttonTexts = buttons.map(b => b.text())
|
const buttonTexts = buttons.map(b => b.text())
|
||||||
expect(buttonTexts).toContain('dSYM')
|
expect(buttonTexts).toContain('dSYM')
|
||||||
expect(buttonTexts).toContain('下载')
|
expect(firstRow.find('.qr-thumb').exists()).toBe(true)
|
||||||
expect(buttonTexts).toContain('二维码')
|
})
|
||||||
|
|
||||||
|
it('普通用户不显示历史操作按钮', async () => {
|
||||||
|
const wrapper = mountHistory(false)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.action-btns').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).not.toContain('删除')
|
||||||
|
expect(wrapper.text()).not.toContain('日志')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('空列表显示提示', async () => {
|
it('空列表显示提示', async () => {
|
||||||
@@ -148,18 +183,27 @@ describe('HistoryView.vue', () => {
|
|||||||
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('点击查看日志跳转', async () => {
|
it('显示 App 版本号,Scheme 与状态单元格允许完整换行', async () => {
|
||||||
const router = createMockRouter()
|
const router = createMockRouter()
|
||||||
router.push = vi.fn()
|
|
||||||
const wrapper = mount(HistoryView, {
|
const wrapper = mount(HistoryView, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router] },
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.formatTime('2024-07-20T10:00:00')).toBe('7/20')
|
||||||
|
expect(wrapper.findAll('tbody tr')[0].text()).toContain('2.196.0')
|
||||||
|
expect(wrapper.find('.scheme-cell').text()).toBe('readoor31OtherPayLongSchemeName')
|
||||||
|
expect(wrapper.find('.status-cell').text()).toContain('已完成')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击查看日志打开日志弹窗', async () => {
|
||||||
|
const wrapper = mountHistory(true)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
|
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
|
||||||
await logBtn.trigger('click')
|
await logBtn.trigger('click')
|
||||||
|
|
||||||
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } })
|
expect(wrapper.find('.log-modal').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('点击二维码弹出弹窗', async () => {
|
it('点击二维码弹出弹窗', async () => {
|
||||||
@@ -169,11 +213,10 @@ describe('HistoryView.vue', () => {
|
|||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码')
|
await wrapper.find('.qr-thumb').trigger('click')
|
||||||
await qrBtn.trigger('click')
|
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(wrapper.find('.qr-modal').exists()).toBe(true)
|
expect(wrapper.find('.qr-preview-modal').exists()).toBe(true)
|
||||||
expect(wrapper.text()).toContain('下载二维码')
|
expect(wrapper.text()).toContain('下载二维码')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,25 +5,37 @@
|
|||||||
<div class="config-panel">
|
<div class="config-panel">
|
||||||
<h2>打包配置</h2>
|
<h2>打包配置</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>选择 App</label>
|
<label>选择服务器环境</label>
|
||||||
<select v-model="form.app_id">
|
<select v-model="selectedServer">
|
||||||
<option value="">请选择...</option>
|
<option value="">请选择...</option>
|
||||||
<option v-for="(app, id) in apps" :key="id" :value="id">
|
<option v-for="server in serverEnvironments" :key="server" :value="server">
|
||||||
{{ app.server }} - {{ app.name }}
|
{{ server }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>选择 App</label>
|
||||||
|
<select v-model="form.app_id" :disabled="!selectedServer">
|
||||||
|
<option value="">{{ selectedServer ? '请选择...' : '请先选择服务器环境' }}</option>
|
||||||
|
<option v-for="[id, app] in filteredApps" :key="id" :value="id">
|
||||||
|
{{ app.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>打包类型</label>
|
<label>打包类型</label>
|
||||||
<select v-model="form.build_type">
|
<select v-model="form.build_type">
|
||||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
<option value="" disabled>请选择...</option>
|
||||||
<option value="App_Store">App_Store</option>
|
<option v-for="buildType in availableBuildTypes" :key="buildType" :value="buildType">
|
||||||
|
{{ buildType }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>选择 Scheme</label>
|
<label>选择 Scheme</label>
|
||||||
<select v-model="form.scheme_id">
|
<select v-model="form.scheme_id">
|
||||||
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
|
<option value="" disabled>请选择...</option>
|
||||||
|
<option v-for="[id, scheme] in filteredSchemes" :key="id" :value="id">
|
||||||
{{ scheme.displayName || scheme.name }}
|
{{ scheme.displayName || scheme.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -41,6 +53,7 @@
|
|||||||
<label for="obfuscation">启用混淆</label>
|
<label for="obfuscation">启用混淆</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="submitNotice" class="submit-notice" role="status" aria-live="polite">{{ submitNotice }}</p>
|
||||||
<button class="btn btn-primary" @click="submitTask" :disabled="submitting">
|
<button class="btn btn-primary" @click="submitTask" :disabled="submitting">
|
||||||
{{ submitting ? '提交中...' : '开始打包' }}
|
{{ submitting ? '提交中...' : '开始打包' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -114,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
|
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section">
|
||||||
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
<img :src="completedTask.qr_code_path" alt="下载二维码" class="qr-image">
|
||||||
<p class="qr-hint">扫码下载安装</p>
|
<p class="qr-hint">扫码下载安装</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +151,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
|
import { computed, ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const getToken = inject('getToken')
|
const getToken = inject('getToken')
|
||||||
@@ -155,14 +168,33 @@ const apps = ref({})
|
|||||||
const schemes = ref({})
|
const schemes = ref({})
|
||||||
const branches = ref(['main'])
|
const branches = ref(['main'])
|
||||||
const tasks = ref([])
|
const tasks = ref([])
|
||||||
|
const selectedServer = ref('')
|
||||||
|
const serverEnvironments = computed(() => {
|
||||||
|
return [...new Set(Object.values(apps.value).map(app => app.server).filter(Boolean))]
|
||||||
|
})
|
||||||
|
const filteredApps = computed(() => {
|
||||||
|
return Object.entries(apps.value).filter(([, app]) => app.server === selectedServer.value)
|
||||||
|
})
|
||||||
|
const selectedApp = computed(() => apps.value[form.value.app_id] || null)
|
||||||
|
const availableBuildTypes = computed(() => {
|
||||||
|
const certificates = selectedApp.value?.certificates || {}
|
||||||
|
return ['Ad_Hoc', 'App_Store'].filter(buildType => Boolean(certificates[buildType]))
|
||||||
|
})
|
||||||
|
const filteredSchemes = computed(() => {
|
||||||
|
const allowedNames = selectedApp.value?.allowed_scheme_names || []
|
||||||
|
return Object.entries(schemes.value).filter(([, scheme]) => {
|
||||||
|
return !allowedNames.length || allowedNames.includes(scheme.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
const form = ref({
|
const form = ref({
|
||||||
app_id: '',
|
app_id: '',
|
||||||
build_type: 'Ad_Hoc',
|
build_type: '',
|
||||||
scheme_id: '1',
|
scheme_id: '',
|
||||||
obfuscation: false,
|
obfuscation: false,
|
||||||
branch: 'main',
|
branch: 'main',
|
||||||
})
|
})
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const submitNotice = ref('')
|
||||||
const currentTaskId = ref(null)
|
const currentTaskId = ref(null)
|
||||||
const completedTask = ref(null)
|
const completedTask = ref(null)
|
||||||
const logs = ref([])
|
const logs = ref([])
|
||||||
@@ -224,7 +256,9 @@ const connectWs = (taskId) => {
|
|||||||
completedTask.value = null
|
completedTask.value = null
|
||||||
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
|
||||||
activeWs = ws
|
activeWs = ws
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
@@ -258,6 +292,20 @@ watch(() => currentTaskId.value, (newId) => {
|
|||||||
connectWs(newId)
|
connectWs(newId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(selectedServer, () => {
|
||||||
|
form.value.app_id = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => form.value.app_id, (appId) => {
|
||||||
|
if (!appId) {
|
||||||
|
form.value.scheme_id = ''
|
||||||
|
form.value.build_type = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.value.scheme_id = filteredSchemes.value[0]?.[0] || ''
|
||||||
|
form.value.build_type = availableBuildTypes.value[0] || ''
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (activeWs) {
|
if (activeWs) {
|
||||||
activeWs.close()
|
activeWs.close()
|
||||||
@@ -306,6 +354,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const submitTask = async () => {
|
const submitTask = async () => {
|
||||||
|
if (submitting.value) return
|
||||||
if (!form.value.app_id) {
|
if (!form.value.app_id) {
|
||||||
alert('请选择 App')
|
alert('请选择 App')
|
||||||
return
|
return
|
||||||
@@ -321,11 +370,20 @@ const submitTask = async () => {
|
|||||||
const task = await res.json()
|
const task = await res.json()
|
||||||
currentTaskId.value = task.id
|
currentTaskId.value = task.id
|
||||||
tasks.value.unshift(task)
|
tasks.value.unshift(task)
|
||||||
|
submitNotice.value = '打包任务已创建,正在排队,请勿重复点击。'
|
||||||
|
selectedServer.value = ''
|
||||||
|
form.value = {
|
||||||
|
app_id: '',
|
||||||
|
build_type: '',
|
||||||
|
scheme_id: '',
|
||||||
|
obfuscation: false,
|
||||||
|
branch: branches.value[0] || 'main',
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('提交失败')
|
submitNotice.value = '提交失败,请检查配置后重试。'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('提交失败')
|
submitNotice.value = '提交失败,请检查网络后重试。'
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -402,6 +460,7 @@ const errorCategoryHint = (cat) => {
|
|||||||
.btn-primary { background: #1890ff; color: white; }
|
.btn-primary { background: #1890ff; color: white; }
|
||||||
.btn-primary:hover { background: #40a9ff; }
|
.btn-primary:hover { background: #40a9ff; }
|
||||||
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; }
|
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||||
|
.submit-notice { margin: 0 0 12px; padding: 9px 12px; border-radius: 6px; background: #e6f7ff; color: #096dd9; font-size: 13px; line-height: 1.5; }
|
||||||
.btn-danger { background: #ff4d4f; color: white; }
|
.btn-danger { background: #ff4d4f; color: white; }
|
||||||
|
|
||||||
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
|
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div v-if="!isAdmin" class="access-denied">
|
<div v-if="!isLoggedIn" class="access-denied">
|
||||||
<h3>需要管理员权限</h3>
|
<h3>请先登录</h3>
|
||||||
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
|
<p>登录后可管理 Apps 配置</p>
|
||||||
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
|
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="admin-page">
|
<div v-else class="admin-page">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<h3>配置管理</h3>
|
<h3>配置管理</h3>
|
||||||
<ul class="sidebar-menu">
|
<ul class="sidebar-menu">
|
||||||
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
|
||||||
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
||||||
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
||||||
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||||
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
||||||
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
||||||
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||||
<li :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>环境名称</th>
|
<th>环境名称</th>
|
||||||
<th>API 地址</th>
|
<th>API 地址</th>
|
||||||
|
<th>App ID 前缀</th>
|
||||||
<th>Associated Domains</th>
|
<th>Associated Domains</th>
|
||||||
<th>Universal Link</th>
|
<th>Universal Link</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
<tr v-for="(server, name) in servers" :key="name">
|
<tr v-for="(server, name) in servers" :key="name">
|
||||||
<td><strong>{{ name }}</strong></td>
|
<td><strong>{{ name }}</strong></td>
|
||||||
<td>{{ server.api }}</td>
|
<td>{{ server.api }}</td>
|
||||||
|
<td>{{ server.app_id_prefix }}</td>
|
||||||
<td>{{ server.assDom }}</td>
|
<td>{{ server.assDom }}</td>
|
||||||
<td>{{ server.universalLink }}</td>
|
<td>{{ server.universalLink }}</td>
|
||||||
<td class="action-btns">
|
<td class="action-btns">
|
||||||
@@ -273,28 +275,25 @@
|
|||||||
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
|
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
|
||||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号</div>
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Build_Ver(构建版本)</label>
|
|
||||||
<input type="text" v-model="versions.build_ver" placeholder="2.180.0.0">
|
|
||||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式:主版本.次版本.修订号.构建号(App_Store 打包时构建号自动 +1)</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
|
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
|
||||||
|
|
||||||
<h4 class="section-title">打包参数</h4>
|
<template v-if="isAdmin">
|
||||||
<div class="form-group">
|
<h4 class="section-title">打包参数</h4>
|
||||||
<label>最大并行打包数</label>
|
<div class="form-group">
|
||||||
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
<label>最大并行打包数</label>
|
||||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
||||||
</div>
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>打包目录保留时间(小时)</label>
|
<div class="form-group">
|
||||||
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
<label>打包目录保留时间(小时)</label>
|
||||||
</div>
|
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>打包基础目录</label>
|
<div class="form-group">
|
||||||
<input type="text" v-model="buildSettings.build_base_dir">
|
<label>打包基础目录</label>
|
||||||
</div>
|
<input type="text" v-model="buildSettings.build_base_dir">
|
||||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
</div>
|
||||||
|
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,7 +324,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码 *</label>
|
<label>密码 *</label>
|
||||||
<input v-model="userForm.password" type="password" placeholder="至少 6 位">
|
<input v-model="userForm.password" type="password" placeholder="至少 11 位">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
@@ -349,7 +348,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>新密码</label>
|
<label>新密码</label>
|
||||||
<input v-model="newPassword" type="password" placeholder="至少 6 位">
|
<input v-model="newPassword" type="password" placeholder="至少 11 位">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||||
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
|
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
|
||||||
@@ -381,6 +380,11 @@
|
|||||||
<label>Universal Link</label>
|
<label>Universal Link</label>
|
||||||
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
|
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>App ID 前缀</label>
|
||||||
|
<input :value="editingServerName ? serverForm.app_id_prefix : nextServerPrefix" type="text" disabled>
|
||||||
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">新增环境自动分配,保存后不可修改。</div>
|
||||||
|
</div>
|
||||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
<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="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
|
||||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
|
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
|
||||||
@@ -405,12 +409,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>服务器环境 *</label>
|
<label>服务器环境 *</label>
|
||||||
<select v-model="appForm.server" @change="onServerChange">
|
<select v-if="isAdmin" v-model="appForm.server" @change="onServerChange">
|
||||||
<option value="">请选择环境</option>
|
<option value="">请选择环境</option>
|
||||||
<option v-for="(server, name) in servers" :key="name" :value="name">
|
<option v-for="(server, name) in servers" :key="name" :value="name">
|
||||||
{{ name }} - {{ server.api }}
|
{{ name }} - {{ server.api }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin" class="form-group">
|
||||||
|
<label>App ID 前缀覆盖</label>
|
||||||
|
<input v-model.number="appForm.app_id_prefix_override" type="number" min="1" placeholder="仅特殊 App 使用">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -479,7 +488,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Provisioning Profile 路径</label>
|
<label>Provisioning Profile 名称</label>
|
||||||
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -557,13 +566,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { computed, ref, onMounted, inject } from 'vue'
|
||||||
|
|
||||||
const showLogin = inject('showLogin')
|
const showLogin = inject('showLogin')
|
||||||
const getToken = inject('getToken')
|
const getToken = inject('getToken')
|
||||||
const isLoggedIn = inject('isLoggedIn')
|
const isLoggedIn = inject('isLoggedIn')
|
||||||
const isAdmin = inject('isAdmin')
|
const isAdmin = inject('isAdmin')
|
||||||
const tab = ref('users')
|
const tab = ref('apps')
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
@@ -593,7 +602,13 @@ const jsonContent = ref('{}')
|
|||||||
|
|
||||||
const showServerModal = ref(false)
|
const showServerModal = ref(false)
|
||||||
const editingServerName = ref(null)
|
const editingServerName = ref(null)
|
||||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '' })
|
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null })
|
||||||
|
const nextServerPrefix = computed(() => {
|
||||||
|
const prefixes = Object.values(servers.value)
|
||||||
|
.map(server => Number(server.app_id_prefix))
|
||||||
|
.filter(prefix => Number.isInteger(prefix) && prefix > 0)
|
||||||
|
return Math.max(0, ...prefixes) + 1
|
||||||
|
})
|
||||||
|
|
||||||
const showAppModal = ref(false)
|
const showAppModal = ref(false)
|
||||||
const editingAppId = ref(null)
|
const editingAppId = ref(null)
|
||||||
@@ -610,6 +625,16 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
const [appsRes, versionsRes] = await Promise.all([
|
||||||
|
authFetch('/api/config/apps'),
|
||||||
|
authFetch('/api/config/versions'),
|
||||||
|
])
|
||||||
|
if (appsRes.ok) apps.value = await appsRes.json()
|
||||||
|
if (versionsRes.ok) versions.value = await versionsRes.json()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
|
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
|
||||||
authFetch('/api/config/apps'),
|
authFetch('/api/config/apps'),
|
||||||
authFetch('/api/config/schemes'),
|
authFetch('/api/config/schemes'),
|
||||||
@@ -643,6 +668,10 @@ const createUser = async () => {
|
|||||||
alert('请填写用户名和密码')
|
alert('请填写用户名和密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (userForm.value.password.length < 11) {
|
||||||
|
alert('密码至少 11 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/users', {
|
const res = await authFetch('/api/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -667,8 +696,8 @@ const openPasswordModal = (u) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changePassword = async () => {
|
const changePassword = async () => {
|
||||||
if (!newPassword.value || newPassword.value.length < 6) {
|
if (!newPassword.value || newPassword.value.length < 11) {
|
||||||
alert('密码至少 6 位')
|
alert('密码至少 11 位')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -716,7 +745,9 @@ const deleteUser = async (u) => {
|
|||||||
// 服务器环境管理
|
// 服务器环境管理
|
||||||
const openServerModal = (name = null, server = null) => {
|
const openServerModal = (name = null, server = null) => {
|
||||||
editingServerName.value = name
|
editingServerName.value = name
|
||||||
serverForm.value = server ? { ...server, name } : { name: '', api: '', assDom: '', universalLink: '' }
|
serverForm.value = server
|
||||||
|
? { ...server, name }
|
||||||
|
: { name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null }
|
||||||
showServerModal.value = true
|
showServerModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +820,7 @@ const openAppModal = (id = null, app = null) => {
|
|||||||
weixinpay: '',
|
weixinpay: '',
|
||||||
tencent: '',
|
tencent: '',
|
||||||
AlivcLicenseKey: '',
|
AlivcLicenseKey: '',
|
||||||
|
app_id_prefix_override: '',
|
||||||
certificates: {},
|
certificates: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1020,7 +1052,7 @@ const saveVersions = async () => {
|
|||||||
const res = await authFetch('/api/config/versions', {
|
const res = await authFetch('/api/config/versions', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(versions.value),
|
body: JSON.stringify({ app_ver: versions.value.app_ver }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<h2>打包历史</h2>
|
<h2>打包历史</h2>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<select v-model="filterBuildType" class="filter-select">
|
<select v-model="filterAppName" class="filter-select">
|
||||||
<option value="">全部类型</option>
|
<option value="">全部 App</option>
|
||||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
|
||||||
<option value="App_Store">App_Store</option>
|
</select>
|
||||||
|
<select v-model="filterVersion" class="filter-select version-filter">
|
||||||
|
<option value="">全部版本</option>
|
||||||
|
<option v-for="version in appVersions" :key="version" :value="version">{{ version }}</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="filterStatus" class="filter-select">
|
<select v-model="filterStatus" class="filter-select">
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
@@ -22,31 +25,26 @@
|
|||||||
<table class="config-table">
|
<table class="config-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>时间</th>
|
<th>App 名称</th>
|
||||||
<th>App</th>
|
<th>App 版本号</th>
|
||||||
<th>打包类型</th>
|
<th>打包类型</th>
|
||||||
<th>Scheme</th>
|
<th>Scheme</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>下载地址</th>
|
<th>下载地址</th>
|
||||||
<th>操作</th>
|
<th v-if="isAdmin">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="task in filteredTasks" :key="task.id">
|
<tr v-for="task in filteredTasks" :key="task.id">
|
||||||
<td>
|
|
||||||
{{ formatTime(task.created_at) }}
|
|
||||||
<span v-if="task.status === 'completed' || task.status === 'failed'" class="duration-text">
|
|
||||||
{{ formatDuration(task.started_at, task.completed_at) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ task.app_name }}</td>
|
<td>{{ task.app_name }}</td>
|
||||||
|
<td>{{ task.app_version }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
||||||
{{ task.build_type }}
|
{{ task.build_type }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ task.scheme_name }}</td>
|
<td class="scheme-cell">{{ task.scheme_name }}</td>
|
||||||
<td>
|
<td class="status-cell">
|
||||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||||
<span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge">
|
<span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge">
|
||||||
{{ errorCategoryLabel(task.error_category) }}
|
{{ errorCategoryLabel(task.error_category) }}
|
||||||
@@ -61,7 +59,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<span v-else class="text-muted">-</span>
|
<span v-else class="text-muted">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="action-btns">
|
<td v-if="isAdmin" class="action-btns">
|
||||||
<button v-if="task.has_log" class="action-btn" @click="viewLogs(task.id)">日志</button>
|
<button v-if="task.has_log" class="action-btn" @click="viewLogs(task.id)">日志</button>
|
||||||
<button v-if="task.status === 'completed' && task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button>
|
<button v-if="task.status === 'completed' && 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.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
|
||||||
@@ -69,7 +67,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!filteredTasks.length">
|
<tr v-if="!filteredTasks.length">
|
||||||
<td colspan="7" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
<td :colspan="isAdmin ? 7 : 6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -162,7 +160,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
|
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
|
||||||
|
|
||||||
const getToken = inject('getToken')
|
const getToken = inject('getToken', () => '')
|
||||||
|
const isAdmin = inject('isAdmin', ref(false))
|
||||||
const tasks = ref([])
|
const tasks = ref([])
|
||||||
|
|
||||||
const authFetch = (url, options = {}) => {
|
const authFetch = (url, options = {}) => {
|
||||||
@@ -172,8 +171,9 @@ const authFetch = (url, options = {}) => {
|
|||||||
}
|
}
|
||||||
return fetch(url, options)
|
return fetch(url, options)
|
||||||
}
|
}
|
||||||
const filterBuildType = ref('')
|
|
||||||
const filterStatus = ref('')
|
const filterStatus = ref('')
|
||||||
|
const filterAppName = ref('')
|
||||||
|
const filterVersion = ref('')
|
||||||
const showLogModal = ref(false)
|
const showLogModal = ref(false)
|
||||||
const logTask = ref(null)
|
const logTask = ref(null)
|
||||||
const logLines = ref([])
|
const logLines = ref([])
|
||||||
@@ -182,6 +182,19 @@ const showVerboseLogs = ref(false)
|
|||||||
const qrPreview = ref(null)
|
const qrPreview = ref(null)
|
||||||
let logWs = null
|
let logWs = null
|
||||||
|
|
||||||
|
const getAppVersion = (task) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(task.config_json || '{}').VERSION || '-'
|
||||||
|
} catch {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appNames = computed(() => [...new Set(tasks.value.map(task => task.app_name).filter(Boolean))].sort())
|
||||||
|
const appVersions = computed(() => [...new Set(
|
||||||
|
tasks.value.map(task => task.app_version).filter(version => version && version !== '-')
|
||||||
|
)].sort((a, b) => b.localeCompare(a, undefined, { numeric: true })))
|
||||||
|
|
||||||
const showQrPreview = (task) => {
|
const showQrPreview = (task) => {
|
||||||
qrPreview.value = task
|
qrPreview.value = task
|
||||||
}
|
}
|
||||||
@@ -190,7 +203,8 @@ const filteredTasks = computed(() => {
|
|||||||
return tasks.value.filter(task => {
|
return tasks.value.filter(task => {
|
||||||
// 默认隐藏已取消的任务
|
// 默认隐藏已取消的任务
|
||||||
if (!filterStatus.value && task.status === 'cancelled') return false
|
if (!filterStatus.value && task.status === 'cancelled') return false
|
||||||
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
|
if (filterAppName.value && task.app_name !== filterAppName.value) return false
|
||||||
|
if (filterVersion.value && task.app_version !== filterVersion.value) return false
|
||||||
if (filterStatus.value && task.status !== filterStatus.value) return false
|
if (filterStatus.value && task.status !== filterStatus.value) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -198,7 +212,8 @@ const filteredTasks = computed(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await authFetch('/api/tasks?limit=100')
|
const res = await authFetch('/api/tasks?limit=100')
|
||||||
tasks.value = await res.json()
|
const data = await res.json()
|
||||||
|
tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewLogs = async (taskId) => {
|
const viewLogs = async (taskId) => {
|
||||||
@@ -262,7 +277,9 @@ const viewLogs = async (taskId) => {
|
|||||||
const connectLogWs = (taskId) => {
|
const connectLogWs = (taskId) => {
|
||||||
if (logWs) { logWs.close(); logWs = null }
|
if (logWs) { logWs.close(); logWs = null }
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
|
||||||
logWs = ws
|
logWs = ws
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
@@ -296,17 +313,24 @@ const downloadObfMaps = (taskId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteTask = async (taskId) => {
|
const deleteTask = async (taskId) => {
|
||||||
if (!confirm('确定要删除这条打包记录吗?')) return
|
if (!confirm('确定要删除这条打包记录及其远端分发文件吗?')) return
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
|
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
|
||||||
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
|
if (res.ok) {
|
||||||
} catch {}
|
tasks.value = tasks.value.filter(t => t.id !== taskId)
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
alert(data.detail || '删除失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('删除失败,请检查网络后重试')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (t) => {
|
const formatTime = (t) => {
|
||||||
if (!t) return '-'
|
if (!t) return '-'
|
||||||
const d = t.endsWith('Z') || t.includes('+') ? new Date(t) : new Date(t + 'Z')
|
const d = t.endsWith('Z') || t.includes('+') ? new Date(t) : new Date(t + 'Z')
|
||||||
return d.toLocaleString()
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDuration = (started, completed) => {
|
const formatDuration = (started, completed) => {
|
||||||
@@ -360,12 +384,12 @@ const errorCategoryLabel = (cat) => {
|
|||||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||||
.config-table td { font-size: 14px; }
|
.config-table td { font-size: 14px; }
|
||||||
.config-table th:nth-child(1) { width: 14%; }
|
.config-table th:nth-child(1) { width: 14%; }
|
||||||
.config-table th:nth-child(2) { width: 14%; }
|
.config-table th:nth-child(2) { width: 12%; }
|
||||||
.config-table th:nth-child(3) { width: 8%; }
|
.config-table th:nth-child(3) { width: 10%; }
|
||||||
.config-table th:nth-child(4) { width: 10%; }
|
.config-table th:nth-child(4) { width: 18%; }
|
||||||
.config-table th:nth-child(5) { width: 10%; }
|
.config-table th:nth-child(5) { width: 15%; }
|
||||||
.config-table th:nth-child(6) { width: 16%; }
|
.config-table th:nth-child(6) { width: 12%; }
|
||||||
.config-table th:nth-child(7) { width: 28%; }
|
.config-table th:nth-child(7) { width: 19%; }
|
||||||
.config-table tr:hover { background: #fafafa; }
|
.config-table tr:hover { background: #fafafa; }
|
||||||
|
|
||||||
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||||
@@ -375,10 +399,13 @@ const errorCategoryLabel = (cat) => {
|
|||||||
.action-btns { white-space: normal; }
|
.action-btns { white-space: normal; }
|
||||||
.action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; }
|
.action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; }
|
||||||
.download-cell { white-space: normal; }
|
.download-cell { white-space: normal; }
|
||||||
|
.scheme-cell { white-space: normal !important; overflow: visible !important; text-overflow: clip !important; overflow-wrap: anywhere; }
|
||||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
.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:hover { border-color: #1890ff; color: #1890ff; }
|
||||||
|
|
||||||
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||||
|
.status-cell { white-space: normal !important; overflow: visible !important; text-overflow: clip !important; overflow-wrap: anywhere; }
|
||||||
|
.status-cell .task-status, .status-cell .error-cat-badge { display: inline-block; margin-bottom: 4px; }
|
||||||
.status-pending { background: #f0f0f0; color: #666; }
|
.status-pending { background: #f0f0f0; color: #666; }
|
||||||
.status-running { background: #e6f7ff; color: #1890ff; }
|
.status-running { background: #e6f7ff; color: #1890ff; }
|
||||||
.status-completed { background: #f6ffed; color: #52c41a; }
|
.status-completed { background: #f6ffed; color: #52c41a; }
|
||||||
@@ -429,9 +456,7 @@ const errorCategoryLabel = (cat) => {
|
|||||||
.meta-label { font-size: 12px; color: #999; }
|
.meta-label { font-size: 12px; color: #999; }
|
||||||
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
|
||||||
|
|
||||||
.duration-text {
|
.version-filter { width: 150px; }
|
||||||
display: block; font-size: 11px; color: #8b949e; margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 日志操作栏 */
|
/* 日志操作栏 */
|
||||||
.log-modal-actions {
|
.log-modal-actions {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ sqlalchemy>=2.0.0
|
|||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
PyJWT>=2.8.0
|
PyJWT>=2.8.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
oss2>=2.19.0
|
oss2>=2.19.0
|
||||||
qrcode[pil]>=7.4.0
|
qrcode[pil]>=7.4.0
|
||||||
|
|||||||
+3
-2
@@ -51,10 +51,11 @@ def tmp_config(tmp_path, monkeypatch):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemes": {
|
"schemes": {
|
||||||
"1": {"name": "testScheme", "ossFloder": "test"}
|
"1": {"name": "readoor31", "ossFloder": "test"}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"},
|
||||||
|
"正式环境": {"api": "https://prod.api.com", "assDom": "applinks:prod.com", "universalLink": "https://prod.com"}
|
||||||
},
|
},
|
||||||
"branches": ["main", "dev"],
|
"branches": ["main", "dev"],
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -20,14 +20,32 @@ def test_get_apps(client, tmp_config):
|
|||||||
|
|
||||||
|
|
||||||
def test_create_app(client, tmp_config):
|
def test_create_app(client, tmp_config):
|
||||||
resp = client.post("/api/config/apps", json={"name": "新App", "AppGuid": "new-guid"})
|
resp = client.post("/api/config/apps", json={
|
||||||
|
"name": "新App", "server": "测试环境", "AppGuid": "new-guid",
|
||||||
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["id"] == "2"
|
assert resp.json()["id"] == "100"
|
||||||
|
|
||||||
# 验证已创建
|
# 验证已创建
|
||||||
apps = client.get("/api/config/apps").json()
|
apps = client.get("/api/config/apps").json()
|
||||||
assert "2" in apps
|
assert "100" in apps
|
||||||
assert apps["2"]["name"] == "新App"
|
assert apps["100"]["name"] == "新App"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_uses_environment_prefix_and_dictionary_exception(client, tmp_config):
|
||||||
|
official = client.post("/api/config/apps", json={"name": "正式 App", "server": "正式环境"})
|
||||||
|
dictionary = client.post("/api/config/apps", json={"name": "英汉大词典", "server": "正式环境"})
|
||||||
|
|
||||||
|
assert official.status_code == 200
|
||||||
|
assert official.json()["id"] == "200"
|
||||||
|
assert dictionary.status_code == 200
|
||||||
|
assert dictionary.json()["id"] == "100"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_app_rejects_unknown_environment_id_rule(client, tmp_config):
|
||||||
|
resp = client.post("/api/config/apps", json={"name": "新 App", "server": "未知环境"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "ID 规则" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_app(client, tmp_config):
|
def test_update_app(client, tmp_config):
|
||||||
@@ -121,7 +139,13 @@ def test_create_server(client, tmp_config):
|
|||||||
"universalLink": "https://new.com",
|
"universalLink": "https://new.com",
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "新环境" in client.get("/api/config/servers").json()
|
servers = client.get("/api/config/servers").json()
|
||||||
|
assert servers["新环境"]["app_id_prefix"] == 3
|
||||||
|
|
||||||
|
client.delete("/api/config/servers/新环境")
|
||||||
|
second = client.post("/api/config/servers", json={"name": "第二环境", "api": "https://second.api.com"})
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert client.get("/api/config/servers").json()["第二环境"]["app_id_prefix"] == 4
|
||||||
|
|
||||||
|
|
||||||
def test_delete_server_in_use(client, tmp_config):
|
def test_delete_server_in_use(client, tmp_config):
|
||||||
@@ -144,3 +168,19 @@ def test_update_build_settings(client, tmp_config):
|
|||||||
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
|
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
|
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Versions ----
|
||||||
|
|
||||||
|
def test_update_versions_resets_build_number(client, tmp_config):
|
||||||
|
resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["app_ver"] == "2.196.0"
|
||||||
|
assert resp.json()["build_ver"] == "2.196.0.0"
|
||||||
|
assert client.get("/api/config/versions").json()["build_ver"] == "2.196.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_versions_rejects_invalid_app_version(client, tmp_config):
|
||||||
|
resp = client.put("/api/config/versions", json={"app_ver": "2.196"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "App_Ver" in resp.json()["detail"]
|
||||||
|
|||||||
+103
-1
@@ -1,4 +1,5 @@
|
|||||||
"""任务接口测试"""
|
"""任务接口测试"""
|
||||||
|
import json
|
||||||
from unittest.mock import patch, AsyncMock
|
from unittest.mock import patch, AsyncMock
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ def test_create_task(mock_queue, client, tmp_config):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["status"] == "pending"
|
assert data["status"] == "pending"
|
||||||
assert data["app_name"] == "测试App"
|
assert data["app_name"] == "测试App"
|
||||||
assert data["scheme_name"] == "testScheme"
|
assert data["scheme_name"] == "readoor31"
|
||||||
assert data["branch"] == "main"
|
assert data["branch"] == "main"
|
||||||
assert data["build_type"] == "Ad_Hoc"
|
assert data["build_type"] == "Ad_Hoc"
|
||||||
|
|
||||||
@@ -61,6 +62,60 @@ def test_create_task_invalid_scheme(mock_queue, client, tmp_config):
|
|||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@patch("backend.services.build_queue.build_queue")
|
||||||
|
def test_create_task_rejects_unconfigured_certificate_type(mock_queue, client, tmp_config):
|
||||||
|
config = json.loads(tmp_config.read_text())
|
||||||
|
config["apps"]["1"]["certificates"] = {
|
||||||
|
"Ad_Hoc": {"name": "com.test.app"},
|
||||||
|
}
|
||||||
|
tmp_config.write_text(json.dumps(config))
|
||||||
|
|
||||||
|
resp = client.post("/api/tasks", json={
|
||||||
|
"app_id": "1", "build_type": "App_Store", "scheme_id": "1", "branch": "main",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "未配置 App_Store 证书" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("backend.services.build_queue.build_queue")
|
||||||
|
def test_create_task_rejects_disallowed_special_app_scheme(mock_queue, client, tmp_config):
|
||||||
|
config = json.loads(tmp_config.read_text())
|
||||||
|
config["apps"]["1"].update({
|
||||||
|
"name": "英汉大词典",
|
||||||
|
"certificates": {"Ad_Hoc": {"name": "com.dictionary.app"}},
|
||||||
|
})
|
||||||
|
config["schemes"] = {
|
||||||
|
"1": {"name": "readoor31"},
|
||||||
|
"2": {"name": "readoorDict"},
|
||||||
|
}
|
||||||
|
tmp_config.write_text(json.dumps(config))
|
||||||
|
|
||||||
|
resp = client.post("/api/tasks", json={
|
||||||
|
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1", "branch": "main",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "readoorDict" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("backend.services.build_queue.build_queue")
|
||||||
|
def test_create_task_rejects_non_default_scheme_for_regular_app(mock_queue, client, tmp_config):
|
||||||
|
config = json.loads(tmp_config.read_text())
|
||||||
|
config["schemes"] = {
|
||||||
|
"1": {"name": "readoor31"},
|
||||||
|
"2": {"name": "readoorDict"},
|
||||||
|
}
|
||||||
|
tmp_config.write_text(json.dumps(config))
|
||||||
|
|
||||||
|
resp = client.post("/api/tasks", json={
|
||||||
|
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "2", "branch": "main",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "readoor31OtherPay" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
@patch("backend.services.build_queue.build_queue")
|
@patch("backend.services.build_queue.build_queue")
|
||||||
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
||||||
mock_queue.submit = AsyncMock()
|
mock_queue.submit = AsyncMock()
|
||||||
@@ -112,3 +167,50 @@ def test_cancel_task(mock_queue, client, tmp_config):
|
|||||||
# 验证状态已更新
|
# 验证状态已更新
|
||||||
task = client.get(f"/api/tasks/{task_id}").json()
|
task = client.get(f"/api/tasks/{task_id}").json()
|
||||||
assert task["status"] == "cancelled"
|
assert task["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_completed_task_removes_remote_artifacts(client, tmp_config):
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.models import Task
|
||||||
|
|
||||||
|
oss_url = "https://files.example.com/test/iOS/1_2_0_0_0_main.html"
|
||||||
|
task = Task(
|
||||||
|
id="completed-task", app_id="1", app_name="测试App", build_type="Ad_Hoc",
|
||||||
|
scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
|
||||||
|
oss_url=oss_url,
|
||||||
|
config_json=json.dumps({"APPID": "1", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "test"}),
|
||||||
|
)
|
||||||
|
db = SessionLocal()
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("backend.services.distribution.delete_published_artifacts") as delete:
|
||||||
|
resp = client.delete("/api/tasks/completed-task/delete")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
delete.assert_called_once()
|
||||||
|
assert delete.call_args.args[2] == oss_url
|
||||||
|
assert client.get("/api/tasks/completed-task").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_task_keeps_shared_remote_artifact(client, tmp_config):
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.models import Task
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
for task_id in ("old-task", "new-task"):
|
||||||
|
db.add(Task(
|
||||||
|
id=task_id, app_id="1", app_name="测试App", build_type="Ad_Hoc",
|
||||||
|
scheme_id="1", scheme_name="readoor31", branch="main", status="completed",
|
||||||
|
oss_url="https://files.example.com/test/iOS/1_2_0_0_0_main.html",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("backend.services.distribution.delete_published_artifacts") as delete:
|
||||||
|
resp = client.delete("/api/tasks/old-task/delete")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "仍被其他记录引用" in resp.json()["message"]
|
||||||
|
delete.assert_not_called()
|
||||||
|
|||||||
+112
-11
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import plistlib
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -13,8 +14,13 @@ import pytest
|
|||||||
from backend.services.build_service import (
|
from backend.services.build_service import (
|
||||||
update_source,
|
update_source,
|
||||||
copy_source_code,
|
copy_source_code,
|
||||||
|
prepare_source_snapshot,
|
||||||
|
build_project,
|
||||||
generate_config,
|
generate_config,
|
||||||
|
run_pod_install,
|
||||||
|
_patch_afnetworking_private_headers,
|
||||||
_cleanup_old_builds,
|
_cleanup_old_builds,
|
||||||
|
_resolve_provisioning_profile,
|
||||||
)
|
)
|
||||||
from backend.services.log_streamer import LogStreamer
|
from backend.services.log_streamer import LogStreamer
|
||||||
|
|
||||||
@@ -35,12 +41,14 @@ def tmp_dirs(tmp_path):
|
|||||||
(source_dir / "Pods").mkdir()
|
(source_dir / "Pods").mkdir()
|
||||||
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
|
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
|
||||||
(source_dir / "readoor.xcworkspace").mkdir()
|
(source_dir / "readoor.xcworkspace").mkdir()
|
||||||
(source_dir / "AutoPacking").mkdir()
|
whitelist_script = source_dir / "AutoPacking" / "obfuscation" / "generate_image_whitelist.py"
|
||||||
|
whitelist_script.parent.mkdir(parents=True)
|
||||||
|
whitelist_script.write_text("# whitelist generator")
|
||||||
vendor_dir = source_dir / "Vendor" / "RDEpubReaderView"
|
vendor_dir = source_dir / "Vendor" / "RDEpubReaderView"
|
||||||
vendor_dir.mkdir(parents=True)
|
vendor_dir.mkdir(parents=True)
|
||||||
(vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end")
|
(vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end")
|
||||||
(source_dir / "readoorTests").mkdir()
|
(source_dir / "readoorTests").mkdir()
|
||||||
(source_dir / "podfile").write_text("pod 'AFNetworking'")
|
(source_dir / "Podfile").write_text("pod 'AFNetworking'")
|
||||||
|
|
||||||
build_dir = tmp_path / "build"
|
build_dir = tmp_path / "build"
|
||||||
return source_dir, build_dir
|
return source_dir, build_dir
|
||||||
@@ -69,10 +77,8 @@ async def test_update_source_clone(tmp_path, log_streamer):
|
|||||||
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"):
|
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"):
|
||||||
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
||||||
await update_source("t1", source_dir, "main")
|
await update_source("t1", source_dir, "main")
|
||||||
mock_exec.assert_called_once()
|
assert mock_exec.call_count == 6 # clone, set-url, fetch, checkout, reset, clean
|
||||||
args = mock_exec.call_args[0]
|
assert mock_exec.call_args_list[0].args[:4] == ("git", "-c", "credential.helper=", "clone")
|
||||||
assert "git" in args
|
|
||||||
assert "clone" in args
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
||||||
@@ -84,8 +90,8 @@ async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
|||||||
await update_source("t1", source_dir, "main")
|
await update_source("t1", source_dir, "main")
|
||||||
|
|
||||||
|
|
||||||
async def test_update_source_pull_existing(tmp_path, log_streamer):
|
async def test_update_source_existing(tmp_path, log_streamer):
|
||||||
"""目录已存在时执行 fetch + checkout + pull"""
|
"""目录已存在时执行 fetch + checkout + reset + clean"""
|
||||||
source_dir = tmp_path / "branches" / "dev"
|
source_dir = tmp_path / "branches" / "dev"
|
||||||
source_dir.parent.mkdir(parents=True)
|
source_dir.parent.mkdir(parents=True)
|
||||||
source_dir.mkdir()
|
source_dir.mkdir()
|
||||||
@@ -102,7 +108,7 @@ async def test_update_source_pull_existing(tmp_path, log_streamer):
|
|||||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||||
await update_source("t1", source_dir, "dev")
|
await update_source("t1", source_dir, "dev")
|
||||||
|
|
||||||
assert call_count == 3 # fetch, checkout, pull
|
assert call_count == 4 # fetch, checkout, reset, clean
|
||||||
|
|
||||||
|
|
||||||
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||||
@@ -110,6 +116,7 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
|||||||
source_dir = tmp_path / "branches" / "dev"
|
source_dir = tmp_path / "branches" / "dev"
|
||||||
source_dir.parent.mkdir(parents=True)
|
source_dir.parent.mkdir(parents=True)
|
||||||
source_dir.mkdir()
|
source_dir.mkdir()
|
||||||
|
(source_dir / ".git").mkdir()
|
||||||
|
|
||||||
async def mock_exec(*args, **kwargs):
|
async def mock_exec(*args, **kwargs):
|
||||||
return _make_mock_process(returncode=1, output=b"error\n")
|
return _make_mock_process(returncode=1, output=b"error\n")
|
||||||
@@ -120,6 +127,22 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
|||||||
await update_source("t1", source_dir, "dev")
|
await update_source("t1", source_dir, "dev")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_prepare_source_snapshot_serializes_shared_source(tmp_dirs, log_streamer):
|
||||||
|
"""共享源码模式下,更新和复制必须在同一把锁内完成。"""
|
||||||
|
source_dir, build_dir_parent = tmp_dirs
|
||||||
|
task = MagicMock(branch="main")
|
||||||
|
|
||||||
|
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
|
||||||
|
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
|
||||||
|
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
|
||||||
|
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \
|
||||||
|
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
|
||||||
|
build_dir, commit = await prepare_source_snapshot("t1", task)
|
||||||
|
|
||||||
|
assert build_dir.exists()
|
||||||
|
assert commit == "abc123"
|
||||||
|
|
||||||
|
|
||||||
# ---- copy_source_code ----
|
# ---- copy_source_code ----
|
||||||
|
|
||||||
async def test_copy_source_code(tmp_dirs, log_streamer):
|
async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||||
@@ -135,10 +158,27 @@ async def test_copy_source_code(tmp_dirs, log_streamer):
|
|||||||
assert result.exists()
|
assert result.exists()
|
||||||
assert (result / "readoor" / "AppDelegate.swift").exists()
|
assert (result / "readoor" / "AppDelegate.swift").exists()
|
||||||
assert (result / "Pods").exists()
|
assert (result / "Pods").exists()
|
||||||
|
assert (result / "Podfile").exists()
|
||||||
|
assert not (result / "podfile").exists()
|
||||||
assert (result / "Podfile.lock").exists()
|
assert (result / "Podfile.lock").exists()
|
||||||
assert (result / "readoor.xcworkspace").exists()
|
assert (result / "readoor.xcworkspace").exists()
|
||||||
assert (result / "Vendor" / "RDEpubReaderView" / "RDEpubReaderView.podspec").exists()
|
assert (result / "Vendor" / "RDEpubReaderView" / "RDEpubReaderView.podspec").exists()
|
||||||
assert not (result / "AutoPacking").exists()
|
assert (result / "AutoPacking" / "obfuscation" / "generate_image_whitelist.py").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_copy_source_code_normalizes_lowercase_podfile(tmp_dirs, log_streamer):
|
||||||
|
"""旧分支的 podfile 也必须复制为 Pods 工程引用的 Podfile。"""
|
||||||
|
source_dir, build_dir_parent = tmp_dirs
|
||||||
|
(source_dir / "Podfile").unlink()
|
||||||
|
(source_dir / "podfile").write_text("pod 'AFNetworking'")
|
||||||
|
task = MagicMock(branch="main")
|
||||||
|
|
||||||
|
with patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent):
|
||||||
|
result = await copy_source_code("t1", task, source_dir)
|
||||||
|
|
||||||
|
copied_names = {path.name for path in result.iterdir()}
|
||||||
|
assert "Podfile" in copied_names
|
||||||
|
assert "podfile" not in copied_names
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer):
|
async def test_copy_source_code_excludes_git(tmp_dirs, log_streamer):
|
||||||
@@ -170,6 +210,52 @@ async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
|
|||||||
assert (result / "readoor").exists()
|
assert (result / "readoor").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_afnetworking_private_headers(tmp_path):
|
||||||
|
"""pod install 后移除新版 Xcode 禁止直接引用的 netinet6 私有头。"""
|
||||||
|
source_dir = tmp_path / "Pods" / "AFNetworking" / "AFNetworking"
|
||||||
|
source_dir.mkdir(parents=True)
|
||||||
|
source_file = source_dir / "AFNetworkReachabilityManager.m"
|
||||||
|
source_file.write_text(
|
||||||
|
"#import <netinet/in.h>\n#import <netinet6/in6.h>\n#import <arpa/inet.h>\n"
|
||||||
|
)
|
||||||
|
source_file.chmod(0o444)
|
||||||
|
|
||||||
|
assert _patch_afnetworking_private_headers(tmp_path) == 1
|
||||||
|
assert "#import <netinet6/in6.h>" not in source_file.read_text()
|
||||||
|
assert "#import <netinet/in.h>" in source_file.read_text()
|
||||||
|
assert source_file.stat().st_mode & 0o777 == 0o444
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_pod_install_patches_afnetworking(tmp_path, log_streamer):
|
||||||
|
source_dir = tmp_path / "Pods" / "AFNetworking"
|
||||||
|
source_dir.mkdir(parents=True)
|
||||||
|
source_file = source_dir / "AFHTTPSessionManager.m"
|
||||||
|
source_file.write_text("#import <netinet6/in6.h>\n")
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()):
|
||||||
|
await run_pod_install("t1", tmp_path)
|
||||||
|
|
||||||
|
assert "netinet6/in6.h" not in source_file.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_build_project_passes_scheme_as_exec_argument(tmp_path, log_streamer):
|
||||||
|
"""Scheme 中的 shell 特殊字符只能作为 xcodebuild 参数,不能被执行。"""
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
task = MagicMock()
|
||||||
|
config_data = {"SCHEME": "App; touch /tmp/should-not-run"}
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec, \
|
||||||
|
patch("asyncio.create_subprocess_shell") as mock_shell:
|
||||||
|
with pytest.raises(Exception, match="未找到 IPA 文件"):
|
||||||
|
await build_project("t1", task, config_data, build_dir)
|
||||||
|
|
||||||
|
assert mock_shell.call_count == 0
|
||||||
|
archive_args = mock_exec.call_args_list[1].args
|
||||||
|
assert archive_args[0:2] == ("xcodebuild", "archive")
|
||||||
|
assert "App; touch /tmp/should-not-run" in archive_args
|
||||||
|
|
||||||
|
|
||||||
# ---- generate_config ----
|
# ---- generate_config ----
|
||||||
|
|
||||||
async def test_generate_config(tmp_path, log_streamer):
|
async def test_generate_config(tmp_path, log_streamer):
|
||||||
@@ -198,7 +284,11 @@ async def test_generate_config(tmp_path, log_streamer):
|
|||||||
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
|
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("backend.routers.config.load_config", return_value=mock_config):
|
with patch("backend.routers.config.load_config", return_value=mock_config), \
|
||||||
|
patch(
|
||||||
|
"backend.services.build_service._resolve_provisioning_profile",
|
||||||
|
return_value=("pro", "TEAM123"),
|
||||||
|
):
|
||||||
result = await generate_config("t1", task, build_dir)
|
result = await generate_config("t1", task, build_dir)
|
||||||
|
|
||||||
assert result["APPID"] == "guid-123"
|
assert result["APPID"] == "guid-123"
|
||||||
@@ -206,6 +296,8 @@ async def test_generate_config(tmp_path, log_streamer):
|
|||||||
assert result["BUILD_TYPE"] == "Ad_Hoc"
|
assert result["BUILD_TYPE"] == "Ad_Hoc"
|
||||||
assert result["BUNDLE_ID"] == "com.test.app"
|
assert result["BUNDLE_ID"] == "com.test.app"
|
||||||
assert result["CERTIFICATE"] == "cert"
|
assert result["CERTIFICATE"] == "cert"
|
||||||
|
assert result["PROVISIONING_NAME"] == "pro"
|
||||||
|
assert result["TEAM_ID"] == "TEAM123"
|
||||||
|
|
||||||
config_file = build_dir / "config_output.json"
|
config_file = build_dir / "config_output.json"
|
||||||
assert config_file.exists()
|
assert config_file.exists()
|
||||||
@@ -213,6 +305,15 @@ async def test_generate_config(tmp_path, log_streamer):
|
|||||||
assert saved["APPID"] == "guid-123"
|
assert saved["APPID"] == "guid-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
|
||||||
|
profile_path = tmp_path / "profile.mobileprovision"
|
||||||
|
profile_path.write_bytes(b"signed profile")
|
||||||
|
decoded = plistlib.dumps({"Name": "a4YX061_adHoc", "TeamIdentifier": ["MG4Z7FU83W"]})
|
||||||
|
|
||||||
|
with patch("backend.services.build_service.subprocess.check_output", return_value=decoded):
|
||||||
|
assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W")
|
||||||
|
|
||||||
|
|
||||||
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
|
||||||
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
"""从服务配置读取版本号,不依赖分支源码脚本"""
|
||||||
build_dir = tmp_path / "build"
|
build_dir = tmp_path / "build"
|
||||||
|
|||||||
+100
-1
@@ -1,7 +1,20 @@
|
|||||||
"""服务端分发产物生成测试。"""
|
"""服务端分发产物生成测试。"""
|
||||||
import plistlib
|
import plistlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from backend.services.distribution import _write_distribution_files, _write_download_page, _write_manifest
|
from backend.services.distribution import (
|
||||||
|
_artifact_stem,
|
||||||
|
delete_published_artifacts,
|
||||||
|
_write_distribution_files,
|
||||||
|
_write_download_page,
|
||||||
|
_write_manifest,
|
||||||
|
publish_ipa,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribution_artifact_name_contains_sanitized_branch():
|
||||||
|
assert _artifact_stem({"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "feature/pay-v2", "BUILD_TYPE": "Ad_Hoc"}) == "100_2_0_0_0_feature_pay-v2_adhoc"
|
||||||
|
assert _artifact_stem({"APPID": "100", "VERSION": "2.0.0.0"}) == "100_2_0_0_0"
|
||||||
|
|
||||||
|
|
||||||
def test_distribution_files_use_current_service_config(tmp_path):
|
def test_distribution_files_use_current_service_config(tmp_path):
|
||||||
@@ -20,3 +33,89 @@ def test_distribution_files_use_current_service_config(tmp_path):
|
|||||||
assert ipa_file.read_bytes() == b"ipa"
|
assert ipa_file.read_bytes() == b"ipa"
|
||||||
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
|
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
|
||||||
assert "itms-services://" in html.read_text(encoding="utf-8")
|
assert "itms-services://" in html.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_store_distribution_uploads_only_ipa(tmp_path):
|
||||||
|
ipa = tmp_path / "source.ipa"
|
||||||
|
ipa.write_bytes(b"app-store-ipa")
|
||||||
|
config = {
|
||||||
|
"APPID": "100",
|
||||||
|
"VERSION": "2.0.0",
|
||||||
|
"BUILD_TYPE": "App_Store",
|
||||||
|
"SOURCE_BRANCH": "main",
|
||||||
|
"OSS_FLODER": "readoor",
|
||||||
|
"_upload_config": {"mode": "oss", "oss": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"backend.services.distribution._upload_oss",
|
||||||
|
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0_main_appstore.ipa"},
|
||||||
|
) as upload:
|
||||||
|
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
|
||||||
|
|
||||||
|
uploaded_files = upload.call_args.args[1]
|
||||||
|
assert len(uploaded_files) == 1
|
||||||
|
assert uploaded_files[0][0].suffix == ".ipa"
|
||||||
|
assert download_url.endswith("100_2_0_0_main_appstore.ipa")
|
||||||
|
assert qr_path == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_adhoc_distribution_uploads_qrcode(tmp_path):
|
||||||
|
ipa = tmp_path / "source.ipa"
|
||||||
|
ipa.write_bytes(b"ad-hoc-ipa")
|
||||||
|
config = {
|
||||||
|
"APPID": "100",
|
||||||
|
"VERSION": "2.0.0",
|
||||||
|
"BUILD_TYPE": "Ad_Hoc",
|
||||||
|
"SOURCE_BRANCH": "dev",
|
||||||
|
"OSS_FLODER": "readoor",
|
||||||
|
"_upload_config": {"mode": "oss", "oss": {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def upload_files(_upload_config, files):
|
||||||
|
local_path, remote_path = files[0]
|
||||||
|
return {local_path.suffix: f"https://files.example.com/{remote_path}"}
|
||||||
|
|
||||||
|
with patch("backend.services.distribution._upload_oss", side_effect=upload_files) as upload:
|
||||||
|
download_url, qr_url = publish_ipa(config, ipa, tmp_path / "build")
|
||||||
|
|
||||||
|
assert download_url.endswith("100_2_0_0_dev_adhoc.html")
|
||||||
|
assert qr_url.endswith("100_2_0_0_dev_adhoc.png")
|
||||||
|
assert [call.args[1][0][0].suffix for call in upload.call_args_list] == [
|
||||||
|
".ipa", ".plist", ".html", ".png",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_adhoc_artifacts_deletes_all_remote_files():
|
||||||
|
config = {"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main", "BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "readoor"}
|
||||||
|
with patch("backend.services.distribution._delete_oss") as delete:
|
||||||
|
delete_published_artifacts(config, {"mode": "oss", "oss": {}})
|
||||||
|
|
||||||
|
assert delete.call_args.args[1] == [
|
||||||
|
"readoor/iOS/100_2_0_0_0_main_adhoc.ipa",
|
||||||
|
"readoor/iOS/100_2_0_0_0_main_adhoc.plist",
|
||||||
|
"readoor/iOS/100_2_0_0_0_main_adhoc.html",
|
||||||
|
"readoor/iOS/100_2_0_0_0_main_adhoc.png",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_uses_saved_url_for_legacy_artifact_name():
|
||||||
|
# 旧任务已有 SOURCE_BRANCH 快照,但上传时仍采用未带分支的旧命名。
|
||||||
|
# 不能再根据当前命名规则推算,否则 OSS 会对不存在的键返回成功。
|
||||||
|
config = {
|
||||||
|
"APPID": "100", "VERSION": "2.0.0.0", "SOURCE_BRANCH": "main",
|
||||||
|
"BUILD_TYPE": "Ad_Hoc", "OSS_FLODER": "readoor",
|
||||||
|
}
|
||||||
|
with patch("backend.services.distribution._delete_oss") as delete:
|
||||||
|
delete_published_artifacts(
|
||||||
|
config,
|
||||||
|
{"mode": "oss", "oss": {"base_url": "https://files.example.com"}},
|
||||||
|
"https://files.example.com/readoor/iOS/100_2_0_0_0.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert delete.call_args.args[1] == [
|
||||||
|
"readoor/iOS/100_2_0_0_0.ipa",
|
||||||
|
"readoor/iOS/100_2_0_0_0.plist",
|
||||||
|
"readoor/iOS/100_2_0_0_0.html",
|
||||||
|
"readoor/iOS/100_2_0_0_0.png",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""钉钉通知测试。"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.services.notification import NotificationError, build_dingtalk_payload, send_dingtalk_notification
|
||||||
|
|
||||||
|
|
||||||
|
def _build_config():
|
||||||
|
return {
|
||||||
|
"SERVER": "测试环境",
|
||||||
|
"VERSION": "2.195.0",
|
||||||
|
"APPID_NAME": "阅门户测试App",
|
||||||
|
"BUNDLE_ID": "cn.touchv.a4YX061",
|
||||||
|
"APPID": "647372741900537856",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_payload_matches_autopacking_content():
|
||||||
|
payload = build_dingtalk_payload(
|
||||||
|
_build_config(),
|
||||||
|
"https://download.example.com/app.html",
|
||||||
|
"https://download.example.com/app.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = payload["markdown"]["text"]
|
||||||
|
assert payload["markdown"]["title"] == "iOS应用下载"
|
||||||
|
assert "## 【iOS】打包信息" in text
|
||||||
|
assert "**环境:** 测试环境" in text
|
||||||
|
assert "**版本:** 2.195.0" in text
|
||||||
|
assert "**APP名称:** 阅门户测试App" in text
|
||||||
|
assert "**包名:** cn.touchv.a4YX061" in text
|
||||||
|
assert "**App Guid:** 647372741900537856" in text
|
||||||
|
assert "**iOS 下载链接:** https://download.example.com/app.html" in text
|
||||||
|
assert "" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_notification_skips_when_disabled():
|
||||||
|
with patch("backend.services.notification.httpx.post") as post:
|
||||||
|
assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_notification_sends_markdown():
|
||||||
|
response = MagicMock(status_code=200)
|
||||||
|
response.json.return_value = {"errcode": 0}
|
||||||
|
with patch("backend.services.notification.httpx.post", return_value=response) as post:
|
||||||
|
assert send_dingtalk_notification(
|
||||||
|
{"enabled": True, "webhook_url": "https://example.com/robot"},
|
||||||
|
_build_config(),
|
||||||
|
"https://download.example.com/app.html",
|
||||||
|
"https://download.example.com/app.png",
|
||||||
|
) is True
|
||||||
|
|
||||||
|
assert post.call_args.kwargs["json"]["markdown"]["title"] == "iOS应用下载"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dingtalk_notification_requires_webhook_when_enabled():
|
||||||
|
with pytest.raises(NotificationError, match="Webhook"):
|
||||||
|
send_dingtalk_notification({"enabled": True}, _build_config(), "https://download.example.com/app")
|
||||||
@@ -19,7 +19,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
|||||||
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
|
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
|
||||||
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n'
|
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n'
|
||||||
"PROVISIONING_PROFILE_SPECIFIER = old;\n"
|
"PROVISIONING_PROFILE_SPECIFIER = old;\n"
|
||||||
'"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n',
|
'"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n'
|
||||||
|
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
|
||||||
|
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
(swift / "RDAppConfiguration.swift").write_text(
|
(swift / "RDAppConfiguration.swift").write_text(
|
||||||
@@ -49,6 +51,10 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
|||||||
|
|
||||||
assert "工程、签名、版本和分发配置已更新" in messages
|
assert "工程、签名、版本和分发配置已更新" in messages
|
||||||
assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text()
|
assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text()
|
||||||
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in (project / "project.pbxproj").read_text()
|
project_content = (project / "project.pbxproj").read_text()
|
||||||
|
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in project_content
|
||||||
|
assert 'CODE_SIGN_IDENTITY = "iPhone Distribution";' in project_content
|
||||||
|
assert '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";' in project_content
|
||||||
|
assert "iPhone Developer" not in project_content
|
||||||
assert (build_dir / "exportOptions.plist").exists()
|
assert (build_dir / "exportOptions.plist").exists()
|
||||||
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""公网部署安全回归测试。"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from backend import config
|
||||||
|
from backend.main import app
|
||||||
|
from backend.security import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def test_passwords_use_bcrypt_and_verify():
|
||||||
|
password_hash = hash_password("a-strong-password")
|
||||||
|
|
||||||
|
assert password_hash.startswith("$2")
|
||||||
|
assert verify_password("a-strong-password", password_hash) == (True, False)
|
||||||
|
assert verify_password("wrong-password", password_hash) == (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejects_connection_without_jwt():
|
||||||
|
with TestClient(app) as unauthenticated_client:
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||||
|
with unauthenticated_client.websocket_connect("/ws/tasks/task-1"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert exc_info.value.code == 1008
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_security_settings(monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "APP_ENV", "production")
|
||||||
|
monkeypatch.setattr(config, "JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
||||||
|
monkeypatch.setattr(config, "ADMIN_PASSWORD", "admin123")
|
||||||
|
monkeypatch.setattr(config, "CORS_ALLOWED_ORIGINS", ["*"])
|
||||||
|
monkeypatch.setattr(config, "TRUSTED_HOSTS", ["*"])
|
||||||
|
monkeypatch.setenv("TRUSTED_HOSTS", "*")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="生产环境安全配置不完整"):
|
||||||
|
config.validate_production_security()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config):
|
||||||
|
"""普通账号可管理 Apps 与版本号,不能读取或修改管理员配置。"""
|
||||||
|
response = client.post("/api/users", json={
|
||||||
|
"username": "builder",
|
||||||
|
"password": "builder-password-123",
|
||||||
|
"is_admin": False,
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
login = client.post("/api/auth/login", json={
|
||||||
|
"username": "builder",
|
||||||
|
"password": "builder-password-123",
|
||||||
|
})
|
||||||
|
token = login.json()["token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
assert client.get("/api/config/apps", headers=headers).status_code == 200
|
||||||
|
assert client.post("/api/config/apps", json={
|
||||||
|
"name": "普通用户 App", "server": "测试环境",
|
||||||
|
}, headers=headers).status_code == 200
|
||||||
|
assert client.get("/api/config/versions", headers=headers).status_code == 200
|
||||||
|
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers)
|
||||||
|
assert versions.status_code == 200
|
||||||
|
assert versions.json()["build_ver"] == "2.196.0.0"
|
||||||
|
assert client.get("/api/config", headers=headers).status_code == 403
|
||||||
|
assert client.get("/api/config/servers", headers=headers).status_code == 403
|
||||||
|
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403
|
||||||
|
assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403
|
||||||
Reference in New Issue
Block a user