diff --git a/.env.example b/.env.example index 50a93fa..19d9ef8 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ FRONTEND_PORT=3000 ADMIN_USERNAME=admin ADMIN_PASSWORD=admin123 +# ---- JWT 认证 ---- +# JWT 签名密钥(生产环境务必修改为随机字符串) +JWT_SECRET=ios-build-server-secret-key-change-in-production + # ---- 并发与清理 ---- # 最大并行打包数 MAX_CONCURRENT_BUILDS=2 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..35ec755 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 3aa55fe..9788511 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # iOS 自动打包服务 -基于 FastAPI + Vue 3 的 iOS 应用自动打包服务,支持 Ad_Hoc 和 App_Store 两种打包类型,提供 Web 界面操作、实时日志流、构建历史管理和健康监测。 +基于 FastAPI + Vue 3 的 iOS 应用自动打包服务,支持 Ad_Hoc 和 App_Store 两种打包类型,提供 Web 界面操作、实时日志流、构建历史管理、多用户权限和健康监测。 ## 目录 @@ -8,6 +8,7 @@ - [快速开始](#快速开始) - [配置说明](#配置说明) - [服务管理](#服务管理) +- [多用户管理](#多用户管理) - [Watchdog 健康监测](#watchdog-健康监测) - [macOS 开机自启](#macos-开机自启) - [开发模式](#开发模式) @@ -114,8 +115,9 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main | 变量 | 默认值 | 说明 | |------|--------|------| | `BACKEND_PORT` | `8000` | 后端服务端口 | -| `ADMIN_USERNAME` | `admin` | 管理员用户名 | -| `ADMIN_PASSWORD` | `admin123` | 管理员密码 | +| `ADMIN_USERNAME` | `admin` | 初始管理员用户名(首次启动自动创建) | +| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 | +| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) | ### 打包配置 @@ -123,6 +125,20 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main |------|--------|------| | `MAX_CONCURRENT_BUILDS` | `2` | 最大并行打包数 | | `BUILD_DIR_RETENTION_HOURS` | `24` | 打包目录保留时间(小时),超期自动清理 | +| `BUILD_TIMEOUT_HOURS` | `1` | 打包超时时间(小时),超时自动标记失败 | + +### Web 管理配置 + +以下配置通过管理后台 Web 界面管理(存储在 `config.json`): + +| 配置项 | 说明 | +|--------|------| +| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 | +| Scheme 管理 | 打包 Scheme 配置 | +| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 | +| 分支管理 | 可打包的代码分支列表 | +| 版本号 | App_Ver 和 Build_Ver(写入 AutoPacking 脚本) | +| 上传配置 | OSS / WebDAV 文件上传设置 | ### Watchdog 配置 @@ -194,6 +210,34 @@ logs/ --- +## 多用户管理 + +系统支持多用户管理,基于 JWT 认证。首次启动时自动创建 `.env` 中配置的管理员账号。 + +### 用户角色 + +| 角色 | 权限 | +|------|------| +| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) | +| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) | + +### 管理用户 + +在管理后台「用户管理」页面(仅管理员可见): + +- 创建用户:设置用户名、密码(至少 6 位)、是否管理员 +- 修改密码:管理员可修改任意用户密码 +- 切换角色:在管理员/普通用户之间切换 +- 删除用户:不能删除自己 + +### 认证流程 + +1. `POST /api/auth/login` — 用户名+密码登录,返回 JWT token +2. 后续请求在 `Authorization: Bearer ` 头中携带 token +3. Token 有效期 24 小时,过期需重新登录 + +--- + ## Watchdog 健康监测 Watchdog 定期检查服务健康状态,发现异常自动重启。 @@ -326,9 +370,9 @@ cd frontend && npx vitest run | `test_build_queue.py` | 并发控制/任务执行/取消 | 7 | | `test_edge_cases.py` | 边界情况/无效参数/配置备份 | 14 | | `App.test.js` | 登录/退出/导航 | 8 | -| `BuildView.vue.test.js` | 打包表单/任务列表/提交 | 8 | -| `ConfigView.vue.test.js` | 配置管理/分支增删/设置保存 | 8 | -| `HistoryView.vue.test.js` | 历史列表/过滤/下载/二维码 | 11 | +| `BuildView.test.js` | 打包表单/任务列表/提交 | 8 | +| `ConfigView.test.js` | 配置管理/分支增删/设置保存 | 8 | +| `HistoryView.test.js` | 历史列表/过滤/下载/二维码 | 11 | --- @@ -362,11 +406,13 @@ BuildServer/ │ ├── main.py # 入口,路由注册,静态文件服务 │ ├── config.py # 配置加载(读取 .env) │ ├── database.py # SQLAlchemy 数据库初始化 -│ ├── models.py # 数据库模型(Task, BuildConfig) +│ ├── models.py # 数据库模型(Task, BuildConfig, User) │ ├── schemas.py # Pydantic 请求/响应模型 +│ ├── deps.py # JWT 认证依赖 │ ├── routers/ -│ │ ├── auth.py # 登录认证 -│ │ ├── config.py # 配置管理(App/Scheme/Server CRUD) +│ │ ├── auth.py # 登录认证(JWT) +│ │ ├── users.py # 用户管理(仅管理员) +│ │ ├── config.py # 配置管理(App/Scheme/Server/Upload/Branch/Version CRUD) │ │ ├── apps.py # 打包选择(只读) │ │ └── tasks.py # 任务管理(创建/列表/取消/下载) │ └── services/ @@ -456,13 +502,17 @@ tail -50 logs/server.log ### Q: 如何修改管理员密码 -编辑 `.env`: +**方法一**:通过管理后台「用户管理」页面修改(推荐) + +**方法二**:编辑 `.env` 后重启(仅影响初始管理员账号): ```bash ADMIN_PASSWORD=new_password ./deploy.sh restart ``` +注意:`.env` 中的管理员账号仅在首次启动时创建,之后的密码修改请通过管理后台操作。 + ### Q: 多台 Mac 部署 每台 Mac 上: diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 16be81e..e7772db 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -309,11 +309,6 @@ 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) diff --git a/backend/services/notification.py b/backend/services/notification.py deleted file mode 100644 index 753b172..0000000 --- a/backend/services/notification.py +++ /dev/null @@ -1,93 +0,0 @@ -"""钉钉通知服务""" -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", ""), - )