18 Commits
Author SHA1 Message Date
shenlei 624f534b0f feat: 限制历史操作为管理员可见 2026-07-20 17:18:00 +09:00
shenlei 13366ab3b4 fix: 修复 OSS 删除与历史筛选 2026-07-20 17:09:28 +09:00
shenlei 44405e589d feat: 优化打包产物与历史筛选 2026-07-20 16:55:17 +09:00
shenlei de33b3d4b2 feat: 按环境前缀生成 Apps ID 2026-07-20 16:24:13 +09:00
shenlei 8747792a4b feat: 根据应用版本自动重置构建版本 2026-07-20 16:03:59 +09:00
shenlei 89fb633b00 feat: 普通用户可维护应用版本号 2026-07-20 15:42:52 +09:00
shenlei 51d7374f2d fix: 提示打包提交并重置表单 2026-07-20 15:28:02 +09:00
shenlei 9b77441ec7 fix: 上传二维码并发送 Ad Hoc 通知 2026-07-20 15:22:44 +09:00
shenlei 8c0bce45a4 fix: 将密码最小长度调整为 11 位 2026-07-20 15:11:02 +09:00
shenlei cf6cc1d21a fix: 打包副本包含 AutoPacking 脚本 2026-07-20 15:03:21 +09:00
shenlei cede7978e4 fix: 输出完整归档日志 2026-07-20 15:00:30 +09:00
shenlei f0c6a2d9b6 fix: 完整显示打包历史信息 2026-07-20 14:57:49 +09:00
shenlei 3e3e4123d0 fix: 完善打包历史与登录提示 2026-07-20 14:54:06 +09:00
shenlei c209ae423c fix: 兼容新版 Xcode 的 AFNetworking 头文件 2026-07-20 14:37:04 +09:00
shenlei 2ad5f208ed fix: 统一打包副本 Podfile 大小写 2026-07-20 14:34:18 +09:00
shenlei 2661435273 修复打包签名并支持 App Store IPA 上传 2026-07-20 14:16:45 +09:00
shenlei 7fd3845447 fix: disable git credential cache during builds 2026-07-20 11:45:26 +09:00
shenandshen a48909f3bc feat: 强化打包配置与服务安全 2026-07-18 13:30:56 +08:00
37 changed files with 1898 additions and 413 deletions
+22 -6
View File
@@ -2,17 +2,21 @@
# 复制此文件为 .env 并修改为实际值
# ---- 目录配置 ----
# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev
# 源码根目录;未配置远程仓库时,每个分支一个子目录
GIT_SOURCE_BASE=./ReadoorBranches
# 配置远程仓库时使用的唯一共享源码工作目录;服务会串行切分支、清理并复制快照
GIT_SOURCE_DIR=./ReadoorBranches/workspace
# 打包输出基础目录(每次打包会在此目录下创建子目录)
BUILD_BASE_DIR=./build
# ---- 分支源码管理 ----
# Git 远程仓库地址,分支目录不存在时自动 clone
# Git 远程仓库地址;配置后所有分支共用 GIT_SOURCE_DIR
GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git
GIT_USERNAME=shenlei
GIT_PASSWORD='&s9U5fbreA'
# 私有仓库:使用只读 Personal Access Token,不要使用账号登录密码。
GIT_USERNAME=your-git-username
GIT_PASSWORD=your-read-repository-token
# ---- 服务端口 ----
BACKEND_PORT=5002
@@ -20,11 +24,23 @@ FRONTEND_PORT=5999
# ---- 管理员账号 ----
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=change-this-before-starting
# ---- 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
# ---- 并发与清理 ----
# 最大并行打包数
+35
View File
@@ -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
+39 -14
View File
@@ -7,6 +7,7 @@
- [环境要求](#环境要求)
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [公网部署](#公网部署)
- [服务管理](#服务管理)
- [多用户管理](#多用户管理)
- [Watchdog 健康监测](#watchdog-健康监测)
@@ -72,11 +73,11 @@ ADMIN_PASSWORD=your_secure_password
启动后访问 `http://<服务器IP>:8000` 即可使用。
### 4.(可选)初始化分支源码目录
### 4.(可选)初始化源码目录
系统现在默认直接从分支源码目录打包,每个分支对应 `GIT_SOURCE_BASE` 下的一个子目录
配置 `GIT_REMOTE_URL` 后,系统默认使用唯一的共享源码目录 `GIT_SOURCE_DIR`。任务会串行执行拉取、切分支、清理和复制,复制完成后在各自的打包目录中并行构建
如果已配置 `GIT_REMOTE_URL`,首次打包某个分支时会自动 clone;如果未配置,则需要先手动准备分支目录:
未配置远程仓库时,需要继续手动准备每个分支目录:
```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_REMOTE_URL`,其他分支无需手动 clone首次打包时会自动拉取
配置远程仓库时,无需手动 clone首次打包会初始化共享目录
### 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` | 打包产物输出基础目录 |
| `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` | 初始管理员密码 |
| `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: 如何按分支打包
1.`.env` 中配置 `GIT_SOURCE_BASE``GIT_REMOTE_URL`
1.`.env` 中配置 `GIT_SOURCE_BASE``GIT_SOURCE_DIR``GIT_REMOTE_URL`
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`
3. 打包时在「代码分支」下拉框中选择分支
4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新
5. 每个分支有独立的源码目录,支持并行打包不同分支
4. 首次打包会初始化共享源码目录;每次任务会串行 `git fetch`、切换目标分支、清理并复制源码快照
5. 快照复制完成后,依赖安装和 Xcode 打包仍在独立目录中并行执行
### Q: 分支目录占满磁盘怎么办
### Q: 源码目录占满磁盘怎么办
```bash
# 查看各分支目录大小
du -sh /path/to/ReadoorBranches/*
# 远程仓库模式下只有一个共享工作目录
du -sh /path/to/ReadoorBranches/workspace
# 删除不用的分支目录
rm -rf /path/to/ReadoorBranches/old-branch
# 打包目录由 BUILD_DIR_RETENTION_HOURS 自动清理;可查看其占用
du -sh /path/to/build/build_readoor_*
```
共享工作目录由服务在每次打包前自动同步和清理。请勿在服务运行期间手动删除或修改该目录。
### Q: 如何运行测试
```bash
+40 -5
View File
@@ -47,10 +47,11 @@ SKINS_DIR = DATA_DIR / "skins"
# 打包基础目录
BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build")))
# Git 分支源码目录
# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录
# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone
# Git 源码目录
# 配置 GIT_REMOTE_URL 时,所有分支共用 GIT_SOURCE_DIR;构建前会串行切换分支并复制快照。
# 未配置远程仓库时,继续兼容 GIT_SOURCE_BASE/<branch> 的人工维护目录。
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_USERNAME = os.getenv("GIT_USERNAME", "")
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:
"""获取指定分支的源码目录"""
"""获取人工维护模式下指定分支的源码目录"""
return GIT_SOURCE_BASE / branch
def get_shared_source_dir() -> Path:
"""获取配置远程仓库时使用的唯一共享源码工作目录。"""
return GIT_SOURCE_DIR
# 需要拷贝的目录和文件
COPY_ITEMS = [
"readoor",
"readoor.xcodeproj",
"readoorTests",
"Vendor",
"podfile",
"AutoPacking",
"Podfile",
"Pods",
"Podfile.lock",
"readoor.xcworkspace",
@@ -108,6 +115,14 @@ COPY_ITEMS = [
# 服务端口
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 = 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_ALGORITHM = "HS256"
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
View File
@@ -38,13 +38,13 @@ def _seed_admin():
"""初始化管理员账号(从 .env 配置)"""
from .models import User
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
import hashlib
from .security import hash_password
db = SessionLocal()
try:
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, password_hash=pw_hash, is_admin=True))
db.add(User(id="admin-001", username=ADMIN_USERNAME,
password_hash=hash_password(ADMIN_PASSWORD), is_admin=True))
db.commit()
finally:
db.close()
+14 -3
View File
@@ -8,9 +8,8 @@ from .config import JWT_SECRET, JWT_ALGORITHM
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""验证 JWT token,返回用户信息"""
token = credentials.credentials
def decode_current_user(token: str) -> dict:
"""验证 JWT token,返回用户身份。"""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
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="登录已过期,请重新登录")
except jwt.InvalidTokenError:
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
View File
@@ -1,15 +1,17 @@
"""FastAPI 主入口"""
from contextlib import asynccontextmanager
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.trustedhost import TrustedHostMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .database import init_db
from .routers import config, apps, tasks, auth, users
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"
@@ -17,6 +19,7 @@ STATIC_DIR = Path(__file__).parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期"""
validate_production_security()
init_db()
yield
@@ -27,14 +30,17 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 仅接受显式允许的跨域前端;同域部署不需要 CORS 配置。
if CORS_ALLOWED_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOWED_ORIGINS,
allow_credentials=False,
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)
@@ -46,8 +52,19 @@ app.include_router(tasks.router)
@app.websocket("/ws/tasks/{task_id}")
async def websocket_logs(websocket: WebSocket, task_id: str):
"""WebSocket 实时日志"""
await websocket.accept()
"""经 JWT 鉴权的 WebSocket 实时日志"""
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:
async for msg in log_streamer.subscribe(task_id):
await websocket.send_json(msg)
+20 -1
View File
@@ -5,12 +5,31 @@ from .config import load_config
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")
async def get_apps_for_build():
"""获取 apps 列表(供打包选择)"""
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")
+47 -9
View File
@@ -1,16 +1,24 @@
"""认证 API"""
import jwt
import threading
import time
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 ..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 ..database import get_db
from ..models import User
from ..security import hash_password, verify_password
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:
@@ -23,19 +31,49 @@ def _create_token(username: str, is_admin: bool) -> str:
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_password(password: str, password_hash: str) -> bool:
"""校验密码"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest() == password_hash
def _check_login_rate_limit(client_ip: str) -> None:
"""按来源 IP 限制登录失败次数,降低在线暴力破解风险。"""
now = time.monotonic()
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)
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()
if not user or not _verify_password(request.password, user.password_hash):
client_ip = request.client.host if request.client else "unknown"
_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="用户名或密码错误")
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)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
+125 -23
View File
@@ -2,11 +2,12 @@
import asyncio
import json
import os
import re
import shutil
import zipfile
from datetime import datetime
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 ..database import get_db
@@ -21,9 +22,19 @@ from ..config import (
DEFAULT_BUILD_DIR_RETENTION_HOURS,
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()
@@ -56,22 +67,26 @@ DEFAULT_SERVERS = {
"测试环境": {
"api": "https://api3-dev.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",
"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",
"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",
"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",
}
# 为已有环境迁移的固定前缀;后续环境从配置中的 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:
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
@@ -141,7 +234,10 @@ def load_config() -> dict:
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
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:
config = json.load(f)
# 确保必要字段存在
@@ -153,8 +249,11 @@ def load_config() -> dict:
config["upload"] = DEFAULT_UPLOAD
if "versions" not in config:
config["versions"] = DEFAULT_VERSIONS.copy()
# 自动为缺少 upload_key 的 app 生成唯一标识
if _ensure_upload_keys(config):
# 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
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)
# 迁移旧式目录皮肤为 ZIP
if _migrate_old_themes(config):
@@ -206,9 +305,9 @@ async def create_app(app: dict):
config = load_config()
apps = config.get("apps", {})
# 自动生成 ID
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
_apply_special_app_prefix_override(app)
new_id = _next_app_id(apps, config.get("servers", {}), app)
# 自动生成 upload_key
if not app.get("upload_key"):
@@ -491,19 +590,18 @@ async def get_versions():
@router.put("/versions")
async def update_versions(data: dict):
"""更新 App_Ver Build_Ver"""
"""更新 App_Ver,并将 Build_Ver 重置为对应的 .0。"""
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:
raise HTTPException(status_code=400, detail="版本号不能为空")
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
build_ver = f"{app_ver}.0"
async with _config_lock:
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
save_config(config)
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:
raise HTTPException(status_code=400, detail="环境名称已存在")
prefix = config.get("next_app_id_prefix", 1)
servers[name] = {
"api": server_data.get("api", ""),
"assDom": server_data.get("assDom", ""),
"universalLink": server_data.get("universalLink", ""),
"app_id_prefix": prefix,
}
config["servers"] = servers
config["next_app_id_prefix"] = prefix + 1
save_config(config)
return {"message": "服务器环境已创建"}
@@ -581,6 +682,7 @@ async def update_server(server_name: str, server_data: dict):
"api": server_data.get("api", ""),
"assDom": server_data.get("assDom", ""),
"universalLink": server_data.get("universalLink", ""),
"app_id_prefix": servers[new_name].get("app_id_prefix"),
}
config["servers"] = servers
save_config(config)
+43 -2
View File
@@ -1,5 +1,6 @@
"""任务 API"""
import os
import json
import shutil
import tempfile
import uuid
@@ -34,6 +35,20 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
app = apps[task.app_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())
db_task = Task(
@@ -42,7 +57,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
app_name=app.get("name", ""),
build_type=task.build_type,
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,
branch=task.branch,
status="pending",
@@ -150,6 +165,29 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if task.status in ("running", "pending"):
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"
if log_path.exists():
@@ -164,7 +202,10 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
db.delete(task)
db.commit()
return {"message": "记录已删除"}
message = "记录已删除"
if task.oss_url:
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
return {"message": message}
@router.get("/{task_id}/log")
+7 -11
View File
@@ -1,12 +1,12 @@
"""用户管理 API(仅管理员)"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
from ..security import hash_password
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="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
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:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6")
if len(password) < 11:
raise HTTPException(status_code=400, detail="密码至少 11")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
password_hash=hash_password(password),
is_admin=is_admin,
)
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="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6")
if not new_password or len(new_password) < 11:
raise HTTPException(status_code=400, detail="密码至少 11")
target.password_hash = _hash_password(new_password)
target.password_hash = hash_password(new_password)
db.commit()
return {"message": "密码已更新"}
+19
View File
@@ -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
View File
@@ -2,7 +2,9 @@
import asyncio
import json
import os
import plistlib
import shutil
import subprocess
import zipfile
from datetime import datetime
from pathlib import Path
@@ -16,11 +18,13 @@ from ..config import (
SKINS_DIR,
AUTOMATION_DIR,
get_source_dir,
get_shared_source_dir,
get_git_remote_url,
mask_git_remote_url,
)
from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa
from .notification import NotificationError, send_dingtalk_notification
from .project_patcher import ProjectPatchError, apply_project_config
@@ -33,6 +37,103 @@ class BuildError(Exception):
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, 友好提示)
_ERROR_RULES = [
(["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:
"""解析构建输出,返回 (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):
"""确保分支源码目录存在且为最新"""
"""将共享源码工作目录强制同步到指定远程分支。"""
remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url)
git_env = _git_env()
if not source_dir.exists():
# 首次:从远程 clone
cloned = not source_dir.exists()
if cloned:
if not remote_url:
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
source_dir.parent.mkdir(parents=True, exist_ok=True)
await log_streamer.emit(task_id, f"克隆仓库: {remote_url_masked}{branch}")
await log_streamer.emit(task_id, f"初始化共享源码目录: {remote_url_masked}")
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,
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 process.wait()
if process.returncode != 0:
raise Exception(f"git clone 失败: {branch}")
else:
# 已存在:fetch + checkout + pull
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
raise Exception("git clone 失败")
if remote_url:
process = await asyncio.create_subprocess_exec(
"git", "remote", "set-url", "origin", remote_url,
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
await process.wait()
# git clone 成功即保证目录有效;已有目录则额外拦截误配置路径。
if not cloned and not (source_dir / ".git").exists():
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
# fetch
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}")
if remote_url:
process = await asyncio.create_subprocess_exec(
"git", "fetch", "origin",
cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE,
*_GIT_NO_CREDENTIAL_HELPER, "remote", "set-url", "origin", remote_url,
env=git_env,
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,
)
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 process.wait()
if process.returncode != 0:
raise Exception("git fetch 失败")
# 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} 失败")
raise Exception(error_message)
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):
"""执行打包任务"""
from ..database import SessionLocal
@@ -251,25 +379,16 @@ async def run_build_task(task_id: str):
async def _do_build():
nonlocal build_dir
# 1. 更新分支源码并拷贝
source_dir = get_source_dir(task.branch)
await asyncio.to_thread(_db_update, db, task, current_step="copy")
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)
# 1. 串行准备源码快照;后续步骤在任务独立目录中并行执行。
await asyncio.to_thread(_db_update, db, task, current_step="source_prepare")
build_dir, source_commit = await prepare_source_snapshot(task_id, task)
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
# 2. 生成配置
await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir)
config_data["SOURCE_BRANCH"] = task.branch
config_data["SOURCE_COMMIT"] = source_commit
await asyncio.to_thread(_db_update, db, task,
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)
await asyncio.to_thread(_db_update, db, task, **update_fields)
# 6. 上传分发(仅 Ad_Hoc
if task.build_type == "Ad_Hoc":
await asyncio.to_thread(_db_update, db, task, current_step="upload")
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path, build_dir)
upload_fields = {}
if oss_url:
upload_fields["oss_url"] = oss_url
if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path
if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
# 6. 上传产物:Ad Hoc 发布安装页,App Store 上传 IPA 供下载提交。
await asyncio.to_thread(_db_update, db, task, current_step="upload")
oss_url, qr_code_path = await upload_ipa(
task_id, task, config_data, ipa_path, build_dir
)
upload_fields = {}
if oss_url:
upload_fields["oss_url"] = oss_url
if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path
if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
# 完成
await asyncio.to_thread(_db_update, db, task,
@@ -325,6 +445,23 @@ async def run_build_task(task_id: str):
if 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)
@@ -398,6 +535,10 @@ async def copy_source_code(task_id: str, task, source_dir: Path) -> Path:
for item in COPY_ITEMS:
src = source_dir / item
# 历史分支中该文件可能仍是小写 podfile;构建副本统一使用 Pods.xcodeproj
# 引用的标准名称 Podfile,避免大小写敏感文件系统报路径不一致。
if item == "Podfile" and not src.is_file():
src = source_dir / "podfile"
dst = build_dir / item
if src.is_dir():
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:
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", "")
if provisioning_profile:
profile_path = provisioning_profile
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
if not os.path.exists(profile_path):
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
if os.path.isdir(profiles_dir):
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
profile_name, team_id = await asyncio.to_thread(
_resolve_provisioning_profile, provisioning_profile
)
config_data["PROVISIONING_NAME"] = profile_name
config_data["TEAM_ID"] = team_id
# 关联域名等配置
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:
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, "依赖安装完成")
@@ -661,14 +778,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
# 清理
await log_streamer.emit(task_id, "清理项目...")
clean_cmd = (
f"xcodebuild clean -workspace {workspace_path.name} "
f"-scheme {scheme} "
f"-configuration Release "
f"-derivedDataPath {export_path / 'derived_data'}"
)
process = await asyncio.create_subprocess_shell(
clean_cmd,
process = await asyncio.create_subprocess_exec(
"xcodebuild", "clean",
"-workspace", workspace_path.name,
"-scheme", scheme,
"-configuration", "Release",
"-derivedDataPath", str(export_path / "derived_data"),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
@@ -681,17 +796,15 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
# 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 = []
process = await asyncio.create_subprocess_shell(
archive_cmd,
process = await asyncio.create_subprocess_exec(
"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),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
@@ -710,15 +823,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
# 导出 IPA
await log_streamer.emit(task_id, "导出 IPA...")
export_plist = build_dir / "exportOptions.plist"
export_cmd = (
f"xcodebuild -exportArchive "
f"-archivePath {archive_path} "
f"-exportPath {export_path} "
f"-exportOptionsPlist {export_plist}"
)
export_output = []
process = await asyncio.create_subprocess_shell(
export_cmd,
process = await asyncio.create_subprocess_exec(
"xcodebuild", "-exportArchive",
"-archivePath", str(archive_path),
"-exportPath", str(export_path),
"-exportOptionsPlist", str(export_plist),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
+113 -8
View File
@@ -1,10 +1,11 @@
"""服务端 Ad_Hoc 分发:OSS/WebDAV 上传、manifest、下载页和二维码"""
"""服务端 IPA 分发:App Store IPA 直传及 Ad Hoc 安装页发布"""
import json
import plistlib
import posixpath
import re
import shutil
from pathlib import Path
from urllib.parse import quote
from urllib.parse import quote, unquote, urlparse
import httpx
@@ -15,7 +16,12 @@ class DistributionError(Exception):
def _artifact_stem(config: dict) -> str:
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]:
@@ -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)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
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}.plist"),
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):
path = ""
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]:
"""发布 IPA,返回下载 URL 与二维码本地路径"""
"""发布 IPA,返回下载 URL 与二维码公开 URL"""
upload = config.get("_upload_config", {})
mode = upload.get("mode", "")
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"
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
# 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)])
_write_manifest(config, manifest, urls[".ipa"])
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:
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
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"]
+80
View File
@@ -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![image]({qr_code_url})"
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
+8
View File
@@ -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']};"),
])
# 与 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 = []
for path, keyword, replacement in replacements:
if _replace_lines(path, keyword, replacement) == 0:
+9 -7
View File
@@ -18,6 +18,7 @@ if [ -f .env ]; then
fi
BACKEND_PORT="${BACKEND_PORT:-8000}"
BIND_HOST="${BIND_HOST:-127.0.0.1}"
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
@@ -159,10 +160,12 @@ do_start() {
mkdir -p "$LOG_DIR"
info "启动后端服务 (端口: $BACKEND_PORT)..."
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host 0.0.0.0 \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
> "$LOG_DIR/server.log" 2>&1 &
@@ -170,11 +173,8 @@ do_start() {
sleep 1
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 "访问地址: http://$access_host:$BACKEND_PORT"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else
error "启动失败,请查看日志: $LOG_DIR/server.log"
rm -f "$PID_FILE"
@@ -382,8 +382,10 @@ do_watchdog_loop() {
setup_venv
mkdir -p "$LOG_DIR"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--host 0.0.0.0 \
--host "$BIND_HOST" \
--port "$BACKEND_PORT" \
--proxy-headers \
--forwarded-allow-ips 127.0.0.1 \
--workers 1 \
>> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"
+40
View 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;
}
}
+1 -3
View File
@@ -6,7 +6,7 @@
<nav v-if="isLoggedIn" class="nav">
<router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link>
<router-link v-if="isAdmin" to="/admin">管理</router-link>
<router-link to="/admin">管理</router-link>
</nav>
<div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button>
@@ -45,7 +45,6 @@
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="login">
</div>
<button class="login-btn" @click="login">登录</button>
<div class="login-hint">默认账号: admin / admin123</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; }
.login-btn { width: 100%; padding: 12px; background: #1890ff; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 8px; }
.login-btn:hover { background: #40a9ff; }
.login-hint { text-align: center; margin-top: 16px; font-size: 12px; color: #999; }
</style>
+11
View File
@@ -65,6 +65,17 @@ describe('App.vue', () => {
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 () => {
fetch.mockResolvedValueOnce({
ok: true,
+37 -14
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import BuildView from '../views/BuildView.vue'
global.fetch = vi.fn()
@@ -11,12 +12,25 @@ class 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', () => {
beforeEach(() => {
vi.clearAllMocks()
fetch.mockImplementation((url) => {
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tasks': [],
@@ -28,7 +42,7 @@ describe('BuildView.vue', () => {
})
it('显示打包配置面板', async () => {
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
await flushPromises()
expect(wrapper.text()).toContain('打包配置')
@@ -39,15 +53,17 @@ describe('BuildView.vue', () => {
})
it('加载 apps 和 schemes', async () => {
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
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)
})
it('加载分支列表并显示下拉', async () => {
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
await flushPromises()
const branchSelect = wrapper.findAll('select').find(s => {
@@ -58,10 +74,10 @@ describe('BuildView.vue', () => {
})
it('默认值正确', async () => {
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
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.branch).toBe('main')
})
@@ -75,7 +91,7 @@ describe('BuildView.vue', () => {
})
}
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tasks': [],
@@ -85,21 +101,28 @@ describe('BuildView.vue', () => {
})
})
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
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 flushPromises()
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
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 () => {
window.alert = vi.fn()
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
await flushPromises()
await wrapper.find('.btn-primary').trigger('click')
@@ -109,7 +132,7 @@ describe('BuildView.vue', () => {
it('显示任务队列', async () => {
fetch.mockImplementation((url) => {
const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
'/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'],
'/api/tasks': [
@@ -122,7 +145,7 @@ describe('BuildView.vue', () => {
})
})
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
await flushPromises()
const taskItems = wrapper.findAll('.task-item')
@@ -130,7 +153,7 @@ describe('BuildView.vue', () => {
})
it('状态文本正确', async () => {
const wrapper = mount(BuildView)
const wrapper = mountBuildView()
await flushPromises()
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')
})
})
+40 -18
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import ConfigView from '../views/ConfigView.vue'
global.fetch = vi.fn()
@@ -23,6 +24,7 @@ function mockFetch(url) {
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'],
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
'/api/config/versions': { app_ver: '2.195.0', build_ver: '2.195.0.0' },
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
}
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', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -39,21 +52,19 @@ describe('ConfigView.vue', () => {
})
it('未登录时显示权限提示', async () => {
const wrapper = mount(ConfigView)
const wrapper = mountConfigView({ loggedIn: false })
await flushPromises()
expect(wrapper.text()).toContain('需要管理员权限')
expect(wrapper.text()).toContain('请先登录')
})
it('已登录时显示管理页面', async () => {
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
const wrapper = mountConfigView()
await flushPromises()
expect(wrapper.text()).toContain('配置管理')
})
it('显示侧边栏菜单', async () => {
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
const wrapper = mountConfigView()
await flushPromises()
const menuItems = wrapper.findAll('.sidebar-menu li')
@@ -65,16 +76,14 @@ describe('ConfigView.vue', () => {
expect(texts).toContain('打包设置')
})
it('默认显示服务器环境 tab', async () => {
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
it('默认显示 Apps 配置 tab', async () => {
const wrapper = mountConfigView()
await flushPromises()
expect(wrapper.text()).toContain('服务器环境配置')
expect(wrapper.text()).toContain('Apps 配置')
})
it('切换到分支管理 tab', async () => {
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
const wrapper = mountConfigView()
await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@@ -93,8 +102,7 @@ describe('ConfigView.vue', () => {
return mockFetch(url)
})
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
const wrapper = mountConfigView()
await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@@ -122,8 +130,7 @@ describe('ConfigView.vue', () => {
return mockFetch(url)
})
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
const wrapper = mountConfigView()
await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@@ -147,8 +154,7 @@ describe('ConfigView.vue', () => {
})
window.alert = vi.fn()
localStorageMock.getItem.mockReturnValue('true')
const wrapper = mount(ConfigView)
const wrapper = mountConfigView()
await flushPromises()
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
@@ -161,4 +167,20 @@ describe('ConfigView.vue', () => {
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)
})
})
+64 -21
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import HistoryView from '../views/HistoryView.vue'
@@ -7,9 +8,9 @@ global.fetch = vi.fn()
window.open = vi.fn()
const mockTasks = [
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' },
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败' },
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' },
{ 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', 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', config_json: '{"VERSION":"2.196.0"}' },
]
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', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -47,7 +57,7 @@ describe('HistoryView.vue', () => {
await flushPromises()
const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(2) // App2(failed) 默认隐藏
expect(rows.length).toBe(3)
})
it('显示任务信息', async () => {
@@ -58,27 +68,27 @@ describe('HistoryView.vue', () => {
await flushPromises()
expect(wrapper.text()).toContain('App1')
// App2 是 failed 状态,默认隐藏
expect(wrapper.text()).not.toContain('App2')
expect(wrapper.text()).toContain('App2')
expect(wrapper.text()).toContain('Ad_Hoc')
})
it('默认隐藏失败和已取消的任务', async () => {
it('默认显示失败任务及完整失败原因', async () => {
const router = createMockRouter()
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
})
await flushPromises()
// 只显示 App1completed)和 App3pending),App2failed)被隐藏
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('App3')
expect(wrapper.text()).toContain('失败')
expect(wrapper.text()).toContain('编译错误')
expect(wrapper.text()).toContain('等待中')
})
it('按打包类型过滤', async () => {
it('版本号筛选为下拉选项', async () => {
const router = createMockRouter()
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
@@ -86,7 +96,9 @@ describe('HistoryView.vue', () => {
await flushPromises()
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()
const rows = wrapper.findAll('tbody tr')
@@ -101,27 +113,50 @@ describe('HistoryView.vue', () => {
await flushPromises()
const selects = wrapper.findAll('.filter-select')
await selects[1].setValue('failed')
await selects[2].setValue('failed')
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(1) // App2
})
it('已完成任务显示下载按钮', async () => {
it('按 App 名称和版本号下拉选项筛选', async () => {
const router = createMockRouter()
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
})
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 和下载按钮
const firstRow = wrapper.findAll('tbody tr')[0]
const buttons = firstRow.findAll('.action-btn')
const buttonTexts = buttons.map(b => b.text())
expect(buttonTexts).toContain('dSYM')
expect(buttonTexts).toContain('下载')
expect(buttonTexts).toContain('二维码')
expect(firstRow.find('.qr-thumb').exists()).toBe(true)
})
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 () => {
@@ -148,18 +183,27 @@ describe('HistoryView.vue', () => {
expect(wrapper.vm.statusText('failed')).toBe('失败')
})
it('点击查看日志跳转', async () => {
it('显示 App 版本号,Scheme 与状态单元格允许完整换行', async () => {
const router = createMockRouter()
router.push = vi.fn()
const wrapper = mount(HistoryView, {
global: { plugins: [router] },
})
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() === '日志')
await logBtn.trigger('click')
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } })
expect(wrapper.find('.log-modal').exists()).toBe(true)
})
it('点击二维码弹出弹窗', async () => {
@@ -169,11 +213,10 @@ describe('HistoryView.vue', () => {
})
await flushPromises()
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码')
await qrBtn.trigger('click')
await wrapper.find('.qr-thumb').trigger('click')
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('下载二维码')
})
})
+73 -14
View File
@@ -5,25 +5,37 @@
<div class="config-panel">
<h2>打包配置</h2>
<div class="form-group">
<label>选择 App</label>
<select v-model="form.app_id">
<label>选择服务器环境</label>
<select v-model="selectedServer">
<option value="">请选择...</option>
<option v-for="(app, id) in apps" :key="id" :value="id">
{{ app.server }} - {{ app.name }}
<option v-for="server in serverEnvironments" :key="server" :value="server">
{{ 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>
</select>
</div>
<div class="form-group">
<label>打包类型</label>
<select v-model="form.build_type">
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
<option value="" disabled>请选择...</option>
<option v-for="buildType in availableBuildTypes" :key="buildType" :value="buildType">
{{ buildType }}
</option>
</select>
</div>
<div class="form-group">
<label>选择 Scheme</label>
<select v-model="form.scheme_id">
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
<option value="" disabled>请选择...</option>
<option v-for="[id, scheme] in filteredSchemes" :key="id" :value="id">
{{ scheme.displayName || scheme.name }}
</option>
</select>
@@ -41,6 +53,7 @@
<label for="obfuscation">启用混淆</label>
</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">
{{ submitting ? '提交中...' : '开始打包' }}
</button>
@@ -114,7 +127,7 @@
</div>
</div>
<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>
</div>
</div>
@@ -138,7 +151,7 @@
</template>
<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'
const getToken = inject('getToken')
@@ -155,14 +168,33 @@ const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
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({
app_id: '',
build_type: 'Ad_Hoc',
scheme_id: '1',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: 'main',
})
const submitting = ref(false)
const submitNotice = ref('')
const currentTaskId = ref(null)
const completedTask = ref(null)
const logs = ref([])
@@ -224,7 +256,9 @@ const connectWs = (taskId) => {
completedTask.value = null
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
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
@@ -258,6 +292,20 @@ watch(() => currentTaskId.value, (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(() => {
if (activeWs) {
activeWs.close()
@@ -306,6 +354,7 @@ onMounted(async () => {
})
const submitTask = async () => {
if (submitting.value) return
if (!form.value.app_id) {
alert('请选择 App')
return
@@ -321,11 +370,20 @@ const submitTask = async () => {
const task = await res.json()
currentTaskId.value = task.id
tasks.value.unshift(task)
submitNotice.value = '打包任务已创建,正在排队,请勿重复点击。'
selectedServer.value = ''
form.value = {
app_id: '',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: branches.value[0] || 'main',
}
} else {
alert('提交失败')
submitNotice.value = '提交失败,请检查配置后重试。'
}
} catch (e) {
alert('提交失败')
submitNotice.value = '提交失败,请检查网络后重试。'
} finally {
submitting.value = false
}
@@ -402,6 +460,7 @@ const errorCategoryHint = (cat) => {
.btn-primary { background: #1890ff; color: white; }
.btn-primary:hover { background: #40a9ff; }
.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; }
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
+73 -41
View File
@@ -1,22 +1,22 @@
<template>
<div class="container">
<div v-if="!isAdmin" class="access-denied">
<h3>需要管理员权限</h3>
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
<div v-if="!isLoggedIn" class="access-denied">
<h3>请先登录</h3>
<p>登录后可管理 Apps 配置</p>
<button class="btn-login" @click="showLogin = true">登录</button>
</div>
<div v-else class="admin-page">
<div class="sidebar">
<h3>配置管理</h3>
<ul class="sidebar-menu">
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li v-if="isAdmin" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</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 === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</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 === 'json' }" @click="tab = 'json'">JSON 编辑</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul>
</div>
<div class="main-content">
@@ -66,6 +66,7 @@
<tr>
<th>环境名称</th>
<th>API 地址</th>
<th>App ID 前缀</th>
<th>Associated Domains</th>
<th>Universal Link</th>
<th>操作</th>
@@ -75,6 +76,7 @@
<tr v-for="(server, name) in servers" :key="name">
<td><strong>{{ name }}</strong></td>
<td>{{ server.api }}</td>
<td>{{ server.app_id_prefix }}</td>
<td>{{ server.assDom }}</td>
<td>{{ server.universalLink }}</td>
<td class="action-btns">
@@ -273,28 +275,25 @@
<input type="text" v-model="versions.app_ver" placeholder="2.180.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</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>
<h4 class="section-title">打包参数</h4>
<div class="form-group">
<label>最大并行打包数</label>
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4过高可能影响构建稳定性</div>
</div>
<div class="form-group">
<label>打包目录保留时间小时</label>
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
</div>
<div class="form-group">
<label>打包基础目录</label>
<input type="text" v-model="buildSettings.build_base_dir">
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
<template v-if="isAdmin">
<h4 class="section-title">打包参数</h4>
<div class="form-group">
<label>最大并行打包数</label>
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4过高可能影响构建稳定性</div>
</div>
<div class="form-group">
<label>打包目录保留时间小时</label>
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
</div>
<div class="form-group">
<label>打包基础目录</label>
<input type="text" v-model="buildSettings.build_base_dir">
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
</template>
</div>
</div>
@@ -325,7 +324,7 @@
</div>
<div class="form-group">
<label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 6 位">
<input v-model="userForm.password" type="password" placeholder="至少 11 位">
</div>
<div class="form-group">
<label class="checkbox-label">
@@ -349,7 +348,7 @@
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 6 位">
<input v-model="newPassword" type="password" placeholder="至少 11 位">
</div>
<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>
@@ -381,6 +380,11 @@
<label>Universal Link</label>
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
</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;">
<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>
@@ -405,12 +409,17 @@
</div>
<div class="form-group">
<label>服务器环境 *</label>
<select v-model="appForm.server" @change="onServerChange">
<select v-if="isAdmin" v-model="appForm.server" @change="onServerChange">
<option value="">请选择环境</option>
<option v-for="(server, name) in servers" :key="name" :value="name">
{{ name }} - {{ server.api }}
</option>
</select>
<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 class="form-row">
@@ -479,7 +488,7 @@
</div>
<div class="form-row">
<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">
</div>
<div class="form-group">
@@ -557,13 +566,13 @@
</template>
<script setup>
import { ref, onMounted, inject } from 'vue'
import { computed, ref, onMounted, inject } from 'vue'
const showLogin = inject('showLogin')
const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin')
const tab = ref('users')
const tab = ref('apps')
// 用户管理
const users = ref([])
@@ -593,7 +602,13 @@ const jsonContent = ref('{}')
const showServerModal = ref(false)
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 editingAppId = ref(null)
@@ -610,6 +625,16 @@ onMounted(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([
authFetch('/api/config/apps'),
authFetch('/api/config/schemes'),
@@ -643,6 +668,10 @@ const createUser = async () => {
alert('请填写用户名和密码')
return
}
if (userForm.value.password.length < 11) {
alert('密码至少 11 位')
return
}
try {
const res = await authFetch('/api/users', {
method: 'POST',
@@ -667,8 +696,8 @@ const openPasswordModal = (u) => {
}
const changePassword = async () => {
if (!newPassword.value || newPassword.value.length < 6) {
alert('密码至少 6 位')
if (!newPassword.value || newPassword.value.length < 11) {
alert('密码至少 11 位')
return
}
try {
@@ -716,7 +745,9 @@ const deleteUser = async (u) => {
// 服务器环境管理
const openServerModal = (name = null, server = null) => {
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
}
@@ -789,6 +820,7 @@ const openAppModal = (id = null, app = null) => {
weixinpay: '',
tencent: '',
AlivcLicenseKey: '',
app_id_prefix_override: '',
certificates: {},
}
}
@@ -1020,7 +1052,7 @@ const saveVersions = async () => {
const res = await authFetch('/api/config/versions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(versions.value),
body: JSON.stringify({ app_ver: versions.value.app_ver }),
})
if (res.ok) {
const data = await res.json()
+60 -35
View File
@@ -4,10 +4,13 @@
<div class="header-row">
<h2>打包历史</h2>
<div class="filters">
<select v-model="filterBuildType" class="filter-select">
<option value="">全部类型</option>
<option value="Ad_Hoc">Ad_Hoc</option>
<option value="App_Store">App_Store</option>
<select v-model="filterAppName" class="filter-select">
<option value="">全部 App</option>
<option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</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 v-model="filterStatus" class="filter-select">
<option value="">全部状态</option>
@@ -22,31 +25,26 @@
<table class="config-table">
<thead>
<tr>
<th>时间</th>
<th>App</th>
<th>App 名称</th>
<th>App 版本号</th>
<th>打包类型</th>
<th>Scheme</th>
<th>状态</th>
<th>下载地址</th>
<th>操作</th>
<th v-if="isAdmin">操作</th>
</tr>
</thead>
<tbody>
<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_version }}</td>
<td>
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
{{ task.build_type }}
</span>
</td>
<td>{{ task.scheme_name }}</td>
<td>
<td class="scheme-cell">{{ task.scheme_name }}</td>
<td class="status-cell">
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
<span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge">
{{ errorCategoryLabel(task.error_category) }}
@@ -61,7 +59,7 @@
</template>
<span v-else class="text-muted">-</span>
</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.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>
@@ -69,7 +67,7 @@
</td>
</tr>
<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>
</tbody>
</table>
@@ -162,7 +160,8 @@
<script setup>
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 authFetch = (url, options = {}) => {
@@ -172,8 +171,9 @@ const authFetch = (url, options = {}) => {
}
return fetch(url, options)
}
const filterBuildType = ref('')
const filterStatus = ref('')
const filterAppName = ref('')
const filterVersion = ref('')
const showLogModal = ref(false)
const logTask = ref(null)
const logLines = ref([])
@@ -182,6 +182,19 @@ const showVerboseLogs = ref(false)
const qrPreview = ref(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) => {
qrPreview.value = task
}
@@ -190,7 +203,8 @@ const filteredTasks = computed(() => {
return tasks.value.filter(task => {
// 默认隐藏已取消的任务
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
return true
})
@@ -198,7 +212,8 @@ const filteredTasks = computed(() => {
onMounted(async () => {
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) => {
@@ -262,7 +277,9 @@ const viewLogs = async (taskId) => {
const connectLogWs = (taskId) => {
if (logWs) { logWs.close(); logWs = null }
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
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
@@ -296,17 +313,24 @@ const downloadObfMaps = (taskId) => {
}
const deleteTask = async (taskId) => {
if (!confirm('确定要删除这条打包记录吗?')) return
if (!confirm('确定要删除这条打包记录及其远端分发文件吗?')) return
try {
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
} catch {}
if (res.ok) {
tasks.value = tasks.value.filter(t => t.id !== taskId)
} else {
const data = await res.json().catch(() => ({}))
alert(data.detail || '删除失败,请稍后重试')
}
} catch {
alert('删除失败,请检查网络后重试')
}
}
const formatTime = (t) => {
if (!t) return '-'
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) => {
@@ -360,12 +384,12 @@ const errorCategoryLabel = (cat) => {
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
.config-table td { font-size: 14px; }
.config-table th:nth-child(1) { width: 14%; }
.config-table th:nth-child(2) { width: 14%; }
.config-table th:nth-child(3) { width: 8%; }
.config-table th:nth-child(4) { width: 10%; }
.config-table th:nth-child(5) { width: 10%; }
.config-table th:nth-child(6) { width: 16%; }
.config-table th:nth-child(7) { width: 28%; }
.config-table th:nth-child(2) { width: 12%; }
.config-table th:nth-child(3) { width: 10%; }
.config-table th:nth-child(4) { width: 18%; }
.config-table th:nth-child(5) { width: 15%; }
.config-table th:nth-child(6) { width: 12%; }
.config-table th:nth-child(7) { width: 19%; }
.config-table tr:hover { background: #fafafa; }
.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 .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; }
.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:hover { border-color: #1890ff; color: #1890ff; }
.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-running { background: #e6f7ff; color: #1890ff; }
.status-completed { background: #f6ffed; color: #52c41a; }
@@ -429,9 +456,7 @@ const errorCategoryLabel = (cat) => {
.meta-label { font-size: 12px; color: #999; }
.meta-value { font-size: 13px; color: #333; font-weight: 500; }
.duration-text {
display: block; font-size: 11px; color: #8b949e; margin-top: 2px;
}
.version-filter { width: 150px; }
/* 日志操作栏 */
.log-modal-actions {
+1
View File
@@ -4,6 +4,7 @@ sqlalchemy>=2.0.0
pydantic>=2.0.0
python-multipart>=0.0.6
PyJWT>=2.8.0
bcrypt>=4.1.0
httpx>=0.25.0
oss2>=2.19.0
qrcode[pil]>=7.4.0
+3 -2
View File
@@ -51,10 +51,11 @@ def tmp_config(tmp_path, monkeypatch):
}
},
"schemes": {
"1": {"name": "testScheme", "ossFloder": "test"}
"1": {"name": "readoor31", "ossFloder": "test"}
},
"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"],
}))
+45 -5
View File
@@ -20,14 +20,32 @@ def test_get_apps(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.json()["id"] == "2"
assert resp.json()["id"] == "100"
# 验证已创建
apps = client.get("/api/config/apps").json()
assert "2" in apps
assert apps["2"]["name"] == "新App"
assert "100" in apps
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):
@@ -121,7 +139,13 @@ def test_create_server(client, tmp_config):
"universalLink": "https://new.com",
})
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):
@@ -144,3 +168,19 @@ def test_update_build_settings(client, tmp_config):
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
assert resp.status_code == 200
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
# ---- 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
View File
@@ -1,4 +1,5 @@
"""任务接口测试"""
import json
from unittest.mock import patch, AsyncMock
@@ -22,7 +23,7 @@ def test_create_task(mock_queue, client, tmp_config):
data = resp.json()
assert data["status"] == "pending"
assert data["app_name"] == "测试App"
assert data["scheme_name"] == "testScheme"
assert data["scheme_name"] == "readoor31"
assert data["branch"] == "main"
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
@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")
def test_list_tasks_after_create(mock_queue, client, tmp_config):
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()
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
View File
@@ -2,6 +2,7 @@
import asyncio
import json
import os
import plistlib
import time
import shutil
import tempfile
@@ -13,8 +14,13 @@ import pytest
from backend.services.build_service import (
update_source,
copy_source_code,
prepare_source_snapshot,
build_project,
generate_config,
run_pod_install,
_patch_afnetworking_private_headers,
_cleanup_old_builds,
_resolve_provisioning_profile,
)
from backend.services.log_streamer import LogStreamer
@@ -35,12 +41,14 @@ def tmp_dirs(tmp_path):
(source_dir / "Pods").mkdir()
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
(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.mkdir(parents=True)
(vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end")
(source_dir / "readoorTests").mkdir()
(source_dir / "podfile").write_text("pod 'AFNetworking'")
(source_dir / "Podfile").write_text("pod 'AFNetworking'")
build_dir = tmp_path / "build"
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("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
await update_source("t1", source_dir, "main")
mock_exec.assert_called_once()
args = mock_exec.call_args[0]
assert "git" in args
assert "clone" in args
assert mock_exec.call_count == 6 # clone, set-url, fetch, checkout, reset, clean
assert mock_exec.call_args_list[0].args[:4] == ("git", "-c", "credential.helper=", "clone")
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")
async def test_update_source_pull_existing(tmp_path, log_streamer):
"""目录已存在时执行 fetch + checkout + pull"""
async def test_update_source_existing(tmp_path, log_streamer):
"""目录已存在时执行 fetch + checkout + reset + clean"""
source_dir = tmp_path / "branches" / "dev"
source_dir.parent.mkdir(parents=True)
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):
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):
@@ -110,6 +116,7 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
source_dir = tmp_path / "branches" / "dev"
source_dir.parent.mkdir(parents=True)
source_dir.mkdir()
(source_dir / ".git").mkdir()
async def mock_exec(*args, **kwargs):
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")
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 ----
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 / "readoor" / "AppDelegate.swift").exists()
assert (result / "Pods").exists()
assert (result / "Podfile").exists()
assert not (result / "podfile").exists()
assert (result / "Podfile.lock").exists()
assert (result / "readoor.xcworkspace").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):
@@ -170,6 +210,52 @@ async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
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 ----
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"}},
}
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)
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["BUNDLE_ID"] == "com.test.app"
assert result["CERTIFICATE"] == "cert"
assert result["PROVISIONING_NAME"] == "pro"
assert result["TEAM_ID"] == "TEAM123"
config_file = build_dir / "config_output.json"
assert config_file.exists()
@@ -213,6 +305,15 @@ async def test_generate_config(tmp_path, log_streamer):
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):
"""从服务配置读取版本号,不依赖分支源码脚本"""
build_dir = tmp_path / "build"
+100 -1
View File
@@ -1,7 +1,20 @@
"""服务端分发产物生成测试。"""
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):
@@ -20,3 +33,89 @@ def test_distribution_files_use_current_service_config(tmp_path):
assert ipa_file.read_bytes() == b"ipa"
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
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",
]
+60
View File
@@ -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 "![image](https://download.example.com/app.png)" 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")
+8 -2
View File
@@ -19,7 +19,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 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",
)
(swift / "RDAppConfiguration.swift").write_text(
@@ -49,6 +51,10 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
assert "工程、签名、版本和分发配置已更新" in messages
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 (logo / "icon-1024.png").read_bytes() == b"icon"
+67
View File
@@ -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