From e18f33d60f572bb7f0430ac6c34f9a5fc4243d4b Mon Sep 17 00:00:00 2001
From: shen <>
Date: Mon, 8 Jun 2026 20:35:05 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A4=E8=AF=81=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E3=80=81=E7=99=BB=E5=BD=95=E5=85=BC=E5=AE=B9=E6=80=A7=E3=80=81?=
=?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=85=8D=E7=BD=AE=E3=80=81=E9=92=89?=
=?UTF-8?q?=E9=92=89=E9=80=9A=E7=9F=A5=E5=8F=8A=E4=BB=A3=E7=A0=81=E6=B8=85?=
=?UTF-8?q?=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 JWT 认证中间件,保护配置管理和任务删除接口
- 修复 ConfigView 登录按钮(Vue 3 inject 替代 $root)
- 页面刷新时通过 /api/auth/me 校验 token 有效性
- max_concurrent_builds 修改后运行时即时生效
- 实现钉钉 webhook 通知(构建成功/失败自动推送)
- 删除未使用的 useWebSocket composable
- log_streamer 使用 LOG_QUEUE_MAX_SIZE 配置常量
---
backend/config.py | 6 +-
backend/deps.py | 20 +++++
backend/routers/auth.py | 32 ++++++--
backend/routers/config.py | 9 ++-
backend/routers/tasks.py | 5 +-
backend/services/build_queue.py | 11 ++-
backend/services/build_service.py | 15 ++++
backend/services/log_streamer.py | 4 +-
backend/services/notification.py | 93 ++++++++++++++++++++++++
frontend/src/App.vue | 38 ++++++++--
frontend/src/composables/useWebSocket.js | 42 -----------
frontend/src/views/ConfigView.vue | 63 +++++++++-------
frontend/src/views/HistoryView.vue | 13 +++-
requirements.txt | 2 +
14 files changed, 262 insertions(+), 91 deletions(-)
create mode 100644 backend/deps.py
create mode 100644 backend/services/notification.py
delete mode 100644 frontend/src/composables/useWebSocket.js
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 @@