feat: 强化打包配置与服务安全

This commit is contained in:
shen
2026-07-18 13:30:56 +08:00
parent d1f070b251
commit a48909f3bc
26 changed files with 779 additions and 203 deletions
+38 -4
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,10 +90,15 @@ 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",
@@ -108,6 +114,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 +145,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) < 12):
errors.append("ADMIN_PASSWORD 必须设置为至少 12 位的强密码")
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)
+12 -3
View File
@@ -6,7 +6,7 @@ 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 +21,18 @@ 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/"):
return user
return require_admin(user)
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(_require_config_permission)])
_config_lock = asyncio.Lock()
+15 -1
View File
@@ -34,6 +34,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 +56,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",
+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) < 12:
raise HTTPException(status_code=400, detail="密码至少 12")
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) < 12:
raise HTTPException(status_code=400, detail="密码至少 12")
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
+96 -92
View File
@@ -16,6 +16,7 @@ from ..config import (
SKINS_DIR,
AUTOMATION_DIR,
get_source_dir,
get_shared_source_dir,
get_git_remote_url,
mask_git_remote_url,
)
@@ -33,6 +34,11 @@ class BuildError(Exception):
self.detail = detail
# 共享源码目录只能同时被一个任务切换、清理和复制。
# 后续在各自 build_dir 中执行的配置、依赖安装和编译不受此锁限制。
source_prepare_lock = asyncio.Lock()
# 错误分类规则:(关键词列表, category, 友好提示)
_ERROR_RULES = [
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
@@ -136,18 +142,18 @@ 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)
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", "clone", remote_url, str(source_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -157,25 +163,32 @@ 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", "remote", "set-url", "origin", remote_url,
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(
*command, cwd=str(source_dir), stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
@@ -184,41 +197,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 +270,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))
@@ -661,14 +671,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 +689,16 @@ 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",
"-quiet",
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
@@ -710,15 +717,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,