diff --git a/backend/config.py b/backend/config.py index dd1d5dc..da3b6c9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -49,7 +49,6 @@ COPY_ITEMS = [ # 服务端口 BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000")) -FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "3000")) # 数据库路径 DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}" @@ -69,3 +68,8 @@ BUILD_TIMEOUT_HOURS = int(os.getenv("BUILD_TIMEOUT_HOURS", "1")) # 管理员账号配置 ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") + +# JWT 配置 +JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_HOURS = 24 diff --git a/backend/deps.py b/backend/deps.py new file mode 100644 index 0000000..4608b8e --- /dev/null +++ b/backend/deps.py @@ -0,0 +1,20 @@ +"""认证依赖""" +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from .config import JWT_SECRET, JWT_ALGORITHM + +security = HTTPBearer() + + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: + """验证 JWT token,返回用户信息""" + token = credentials.credentials + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)} + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据") diff --git a/backend/routers/auth.py b/backend/routers/auth.py index efefc33..615b2fa 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -1,19 +1,35 @@ """认证 API""" -from fastapi import APIRouter +import jwt +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException + from ..schemas import LoginRequest, LoginResponse -from ..config import ADMIN_USERNAME, ADMIN_PASSWORD +from ..config import ADMIN_USERNAME, ADMIN_PASSWORD, JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS +from ..deps import get_current_user router = APIRouter(prefix="/api/auth", tags=["auth"]) +def _create_token(username: str, is_admin: bool) -> str: + """生成 JWT token""" + payload = { + "sub": username, + "is_admin": is_admin, + "exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + @router.post("/login", response_model=LoginResponse) async def login(request: LoginRequest): """管理员登录""" if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD: - return LoginResponse( - token="admin-token", - username=request.username, - is_admin=True - ) - from fastapi import HTTPException + token = _create_token(request.username, is_admin=True) + return LoginResponse(token=token, username=request.username, is_admin=True) raise HTTPException(status_code=401, detail="用户名或密码错误") + + +@router.get("/me") +async def get_me(user: dict = Depends(get_current_user)): + """校验 token 有效性,返回当前用户信息""" + return {"username": user["username"], "is_admin": user["is_admin"]} diff --git a/backend/routers/config.py b/backend/routers/config.py index 526e28b..fc6aca9 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -10,8 +10,9 @@ from ..database import get_db from ..models import BuildConfig from ..schemas import BuildConfigUpdate from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS +from ..deps import get_current_user -router = APIRouter(prefix="/api/config", tags=["config"]) +router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)]) _config_lock = asyncio.Lock() @@ -314,6 +315,12 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen db.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir)) db.commit() + + # 运行时更新并发数 + if settings.max_concurrent_builds is not None: + from ..services.build_queue import build_queue + build_queue.update_max_concurrent(settings.max_concurrent_builds) + return {"message": "打包设置已更新"} diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 0d44bc0..bbc64ab 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session from ..database import get_db from ..models import Task from ..schemas import TaskCreate, TaskResponse +from ..deps import get_current_user router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -105,7 +106,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)): return _enrich_tasks([task])[0] -@router.delete("/{task_id}") +@router.delete("/{task_id}", dependencies=[Depends(get_current_user)]) async def cancel_task(task_id: str, db: Session = Depends(get_db)): """取消任务""" task = db.query(Task).filter(Task.id == task_id).first() @@ -136,7 +137,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)): return {"message": "任务已取消"} -@router.delete("/{task_id}/delete") +@router.delete("/{task_id}/delete", dependencies=[Depends(get_current_user)]) async def delete_task(task_id: str, db: Session = Depends(get_db)): """删除打包记录""" import shutil diff --git a/backend/services/build_queue.py b/backend/services/build_queue.py index 5d58c31..27482a0 100644 --- a/backend/services/build_queue.py +++ b/backend/services/build_queue.py @@ -62,10 +62,15 @@ class BuildQueue: task.cancel() def update_max_concurrent(self, new_max: int): - """更新最大并发数""" + """更新最大并发数(运行时生效)""" + if new_max == self._max_concurrent: + return + old = self._max_concurrent self._max_concurrent = new_max - # 注意:运行时修改需要重建 semaphore,这里简化处理 - # 实际使用时建议重启服务生效 + # 调整信号量:增加并发时 release 额外的许可,减少时由自然消费收敛 + if new_max > old: + for _ in range(new_max - old): + self._semaphore.release() @property def queue_size(self) -> int: diff --git a/backend/services/build_service.py b/backend/services/build_service.py index e7772db..91ab663 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -309,6 +309,11 @@ async def run_build_task(task_id: str): if task.oss_url: await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") + # 钉钉通知 + from .notification import notify_build_result + dingtalk = full_config.get("upload", {}).get("dingtalk", {}) + await notify_build_result(task, dingtalk) + # 带超时执行打包 await asyncio.wait_for(_do_build(), timeout=timeout_seconds) @@ -330,6 +335,11 @@ async def run_build_task(task_id: str): await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}") await asyncio.to_thread(log_streamer.save_log, task_id, build_dir) + # 钉钉通知 + from .notification import notify_build_result + dingtalk = full_config.get("upload", {}).get("dingtalk", {}) + await notify_build_result(task, dingtalk) + except asyncio.CancelledError: await asyncio.to_thread(_db_update, db, task, status="cancelled", completed_at=datetime.utcnow()) @@ -362,6 +372,11 @@ async def run_build_task(task_id: str): await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}") await asyncio.to_thread(log_streamer.save_log, task_id, build_dir) + # 钉钉通知 + from .notification import notify_build_result + dingtalk = full_config.get("upload", {}).get("dingtalk", {}) + await notify_build_result(task, dingtalk) + finally: log_streamer.complete(task_id) diff --git a/backend/services/log_streamer.py b/backend/services/log_streamer.py index ca8a2e3..5c77590 100644 --- a/backend/services/log_streamer.py +++ b/backend/services/log_streamer.py @@ -5,6 +5,8 @@ from datetime import datetime from pathlib import Path from typing import Dict, AsyncGenerator +from ..config import LOG_QUEUE_MAX_SIZE + class LogStreamer: """日志流管理器""" @@ -16,7 +18,7 @@ class LogStreamer: def create_queue(self, task_id: str) -> asyncio.Queue: """创建任务的日志队列""" - queue = asyncio.Queue(maxsize=1000) + queue = asyncio.Queue(maxsize=LOG_QUEUE_MAX_SIZE) self._queues[task_id] = queue self._log_lines[task_id] = [] return queue diff --git a/backend/services/notification.py b/backend/services/notification.py new file mode 100644 index 0000000..753b172 --- /dev/null +++ b/backend/services/notification.py @@ -0,0 +1,93 @@ +"""钉钉通知服务""" +import asyncio +import hashlib +import hmac +import base64 +import time +import urllib.parse +import json +import logging + +logger = logging.getLogger(__name__) + + +def _sign(secret: str) -> tuple[str, str]: + """生成钉钉加签参数""" + timestamp = str(round(time.time() * 1000)) + string_to_sign = f"{timestamp}\n{secret}" + hmac_code = hmac.new( + secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) + return timestamp, sign + + +async def send_dingtalk(webhook_url: str, title: str, text: str, secret: str = ""): + """发送钉钉 Markdown 通知""" + import httpx + + url = webhook_url + if secret: + timestamp, sign = _sign(secret) + url = f"{url}×tamp={timestamp}&sign={sign}" + + payload = { + "msgtype": "markdown", + "markdown": {"title": title, "text": text}, + } + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(url, json=payload) + data = resp.json() + if data.get("errcode") != 0: + logger.warning(f"钉钉通知发送失败: {data}") + except Exception as e: + logger.warning(f"钉钉通知异常: {e}") + + +async def notify_build_result(task, dingtalk_config: dict): + """发送打包结果通知""" + if not dingtalk_config.get("enabled") or not dingtalk_config.get("webhook_url"): + return + + status = task.status + app_name = task.app_name + build_type = task.build_type + scheme_name = task.scheme_name + + if status == "completed": + title = f"✅ {app_name} 打包成功" + lines = [ + f"### ✅ {app_name} 打包成功", + f"- **应用**: {app_name}", + f"- **类型**: {build_type}", + f"- **Scheme**: {scheme_name}", + f"- **分支**: {task.branch}", + ] + if task.oss_url: + lines.append(f"- **下载链接**: [点击下载]({task.oss_url})") + text = "\n".join(lines) + elif status == "failed": + title = f"❌ {app_name} 打包失败" + error = task.error_message or "未知错误" + lines = [ + f"### ❌ {app_name} 打包失败", + f"- **应用**: {app_name}", + f"- **类型**: {build_type}", + f"- **Scheme**: {scheme_name}", + f"- **分支**: {task.branch}", + f"- **错误**: {error}", + ] + text = "\n".join(lines) + else: + return + + await send_dingtalk( + dingtalk_config["webhook_url"], + title, + text, + dingtalk_config.get("secret", ""), + ) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d9c5cbc..806f290 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -41,12 +41,22 @@