23 Commits
Author SHA1 Message Date
shenleiandClaude Opus 5 64715a2c9e feat: 腾讯 AppID 自动补全 tencent 前缀
新增/编辑 App 时,腾讯 AppID 允许只填数字,后端统一归一化为
tencent 前缀;前端输入框失焦即时补全,保存前再校正一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:27:36 +09:00
shenleiandClaude Opus 5 21542c88d7 feat: 版本号按分支轨道分开管理,版本号配置收归管理员
master/develop 共用 release 轨(App_Store 打包构建号自增),feature 分支走
feature 轨(构建号永不自增,始终使用手填值),两条轨道的 App_Ver 各自独立。

- versions 改为 tracks 结构,旧的单轨/单值配置在加载时自动迁移进 release 轨
- 新增 config["branch_track"] 记录分支归属,缺省按分支名推断,可在分支管理页改
- 新增 PUT /api/config/branches/track;分支名含 / 时走请求体而非路径参数
- 修复 DELETE /api/config/branches/{name} 无法删除含 / 的分支(405)
- 版本号接口不再对普通用户开放,打包设置页整页改为管理员可见

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:56:10 +09:00
shenleiandClaude Opus 4.8 3cb4053ab5 chore: 运行时 config.json 不再纳入 git 跟踪,改用 config.example.json 模板
config.json 是运行时高频写入的数据文件(网页改配置、每次 App_Store 打包递增
构建号都会写盘),纳入版本控制会导致服务器每次 git pull 与本地生产配置冲突。

- git rm --cached backend/data/config.json(保留磁盘文件,仅移出索引)
- .gitignore 显式忽略 backend/data/config.json 并补充说明
- 新增 config.example.json 作为结构模板(不含真实密钥/生产数据)
- README 说明其为运行时文件、pull 不覆盖,及从模板初始化的方式

首次部署仍可通过根目录 config.json 自动导入为 backend/data/config.json。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:12:09 +09:00
shenleiandClaude Opus 4.8 91e9b027e8 feat: 构建号按版本号(App_Ver)独立记录,避免回退旧版本号被 App Store 拒
将 versions.build_ver 单值改为 build_map(每个 App_Ver 独立记录已用到的最大
构建号)。App Store 的构建号唯一性按 MARKETING_VERSION 分别计算,改为单一全局
计数器时回退到打过包的旧版本号会重复(如 2.195.0 已到 .5,切 2.196.0 后再回退,
重置/沿用全局计数都会 < .5 而被拒)。

- config.py: 新增 _ensure_versions 迁移旧 build_ver → build_map;PUT /versions
  不再重置构建号,改版本号仅切换当前 App_Ver,并支持可选手动设置 build_ver;
  GET /versions 返回 build_map 及当前版本号对应的 build_ver
- build_service.py: App_Store 打包时在 build_map[app_ver] 自身上 +1 并回写
- ConfigView.vue: 打包设置页显示可编辑的 Build_Ver,修改 App_Ver 时自动切换为
  该版本号已记录的构建号
- 迁移 config.json;更新前后端相关测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:49:14 +09:00
shenleiandClaude Opus 4.8 b6e56f5f9a docs: README 补充一键部署(up)、就绪探测与 Git 推送说明
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:58:22 +09:00
shenleiandClaude Opus 4.8 b2982ae8f7 feat: deploy.sh 支持一键部署与就绪探测
- 新增 up 子命令:build + start + watchdog 一键强制重来
- start 改为轮询 /api/health 就绪才返回,消除启动竞态误报
- start 增加端口占用守卫:非本项目进程占用时明确报错
- watchdog 增加启动宽限(WATCHDOG_STARTUP_GRACE),避开启动窗口误重启
- watchdog 支持 launchd 模式(WATCHDOG_RESTART_MODE),用 launchctl
  kickstart 重启以避免与 KeepAlive 抢端口
- 抽取 _spawn_server/_wait_ready/foreign_port_holder 公共函数

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:50:59 +09:00
shenleiandClaude Opus 4.8 9d766a0b58 fix: 皮肤包替换忽略 __MACOSX 及隐藏文件
macOS 压缩产生的皮肤 zip 顶层含 __MACOSX 目录,导致解压后判定为
多目录、退回使用解压根目录作 THEME,从而把 __MACOSX 和真实皮肤目录
一并复制进 Images.xcassets。解压统计顶层条目时过滤 __MACOSX 与隐藏
文件,并在 _apply_theme 复制时兜底跳过,使 THEME 正确下钻到真实皮肤
目录。旧皮肤包不含这些条目,行为不变,完全兼容。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:32:45 +09:00
shenleiandClaude Opus 4.8 605c09c88a feat: 皮肤包上传区支持拖拽上传
- 抽出 uploadSkinFile 复用点击与拖拽两种上传方式
- 拖入时高亮上传区并提示,松开即触发上传

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:20:04 +09:00
shenleiandClaude Opus 4.8 5014376b81 feat: 支持皮肤包就地更新与新建即传
- 新增皮肤包更新接口,覆盖内容保持文件名不变,避免 APP 配置的主题引用失效
- 上传新皮肤后自动为空主题目录的证书选中该皮肤
- 新建 APP 保存后不关弹窗并回填 upload_key,可直接上传皮肤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:14:08 +09:00
shenlei bb8a0afeac feat: 自动隐藏被覆盖的打包历史 2026-07-24 12:05:37 +09:00
shenlei 4dccc4d96b feat: 增加操作审计与受控下载 2026-07-22 18:12:19 +09:00
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
40 changed files with 2274 additions and 743 deletions
+1
View File
@@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败 # 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1 BUILD_TIMEOUT_HOURS=1
DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ---- # ---- Watchdog 健康监测 ----
# 健康检查间隔(秒) # 健康检查间隔(秒)
+3
View File
@@ -12,7 +12,10 @@ __pycache__/
# 运行时数据 # 运行时数据
backend/data/*.bak backend/data/*.bak
# 运行时配置:网页 UI 改配置、每次 App_Store 打包都会写盘,不纳入版本控制。
# 结构参见仓库根目录的 config.example.json;首次部署可 cp 一份作为初始种子。
config.json config.json
backend/data/config.json
ReadoorBranches/ ReadoorBranches/
build/ build/
+3 -2
View File
@@ -25,11 +25,12 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu
- `backend/deps.py` — JWT auth dependency used by all protected routes - `backend/deps.py` — JWT auth dependency used by all protected routes
- `backend/config.py` — loads .env, defines all config constants - `backend/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView - `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI - `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI
- Versions are per-track: `release` (master/develop, build number auto-increments on App_Store) and `feature` (self-test, never increments); each branch's track lives in `config["branch_track"]`
## Conventions ## Conventions
- All backend routes require JWT auth except `/api/auth/login` and `/api/health` - All backend routes require JWT auth except `/api/auth/login` and `/api/health`
- Admin-only routes: user management (`/api/users/*`) - Admin-only routes: user management (`/api/users/*`) and all of `/api/config/*` except `/api/config/apps*`
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings) - Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
- Chinese UI and documentation throughout - Chinese UI and documentation throughout
+74 -12
View File
@@ -13,6 +13,7 @@
- [Watchdog 健康监测](#watchdog-健康监测) - [Watchdog 健康监测](#watchdog-健康监测)
- [macOS 开机自启](#macos-开机自启) - [macOS 开机自启](#macos-开机自启)
- [开发模式](#开发模式) - [开发模式](#开发模式)
- [推送代码到 Git 服务器](#推送代码到-git-服务器)
- [自动化测试](#自动化测试) - [自动化测试](#自动化测试)
- [项目结构](#项目结构) - [项目结构](#项目结构)
- [常见问题](#常见问题) - [常见问题](#常见问题)
@@ -63,11 +64,24 @@ ADMIN_PASSWORD=your_secure_password
### 3. 构建与启动 ### 3. 构建与启动
推荐一键部署(构建 + 启动 + 健康监测,一步到位):
```bash
# 一键部署:build + start + watchdog(强制重来)
./deploy.sh up
```
`up` 会自动构建前端、(强制)重启后端并**轮询 `/api/health` 直到就绪**才返回,再拉起 watchdog,最后打印状态。适合首次部署和更新代码后重新上线。
> ⚠️ `up`/`start` 采用「强制重来」语义:若检测到旧服务会先停再起,正在进行的打包任务会被中断。
也可以分步执行:
```bash ```bash
# 一键构建(安装依赖 + 构建前端) # 一键构建(安装依赖 + 构建前端)
./deploy.sh build ./deploy.sh build
# 启动服务 # 启动服务(等待就绪;端口被非本项目进程占用时会明确报错)
./deploy.sh start ./deploy.sh start
``` ```
@@ -156,19 +170,39 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
### Web 管理配置 ### Web 管理配置
以下配置通过管理后台 Web 界面管理(存储在 `config.json`): 以下配置通过管理后台 Web 界面管理(存储在 `backend/data/config.json`):
首次部署时,如果项目根目录下存在 `config.json`,系统会自动导入其中的 `apps``schemes` 作为初始配置;导入后即可继续在后台增删改 > ⚠️ `config.json` 是**运行时数据文件**(网页改配置、每次 App_Store 打包写入构建号都会改写它),**不纳入版本控制**,`git pull` 不会覆盖它。仓库仅提供结构模板 `config.example.json`
>
> 首次部署时,若项目根目录下存在 `config.json`,系统会自动将其复制为 `backend/data/config.json` 作为初始配置。可从模板起步:
>
> ```bash
> cp config.example.json config.json # 按需填入真实的 apps / servers / upload 等,再启动
> ```
>
> 导入后即可继续在后台增删改。
| 配置项 | 说明 | | 配置项 | 说明 |
|--------|------| |--------|------|
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 | | App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
| Scheme 管理 | 打包 Scheme 配置 | | Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 | | 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表 | | 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道 |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码 | | 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下) |
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 | | 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
### 版本号与版本轨道
版本号按 **轨道(track** 分开管理,两条轨道的 App_Ver 各自独立填写、互不影响:
| 轨道 | 适用分支 | 构建号行为 |
|------|----------|-----------|
| `release`(上架) | master、develop(共用同一套版本号与构建号序列) | App_Store 打包时按当前 App_Ver 自动 +1 |
| `feature`(自测) | feature/* 等其他分支 | 永不自增,始终使用配置页填写的值(默认 `.0` |
- 分支归属默认按名字推断(master/main/develop/dev/release-*/hotfix-* → release,其余 → feature),可在「分支管理」页逐个改。
- 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。
### 服务端自动化资源 ### 服务端自动化资源
项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。 项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
@@ -179,6 +213,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|------|--------|------| |------|--------|------|
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) | | `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) | | `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) |
| `WATCHDOG_STARTUP_GRACE` | `10` | 首次检查前的宽限(秒),避开服务正常启动窗口,防止误重启 |
| `STARTUP_READY_TIMEOUT` | `20` | `start` 后等待 `/api/health` 就绪的最长时间(秒) |
| `WATCHDOG_RESTART_MODE` | `nohup` | 重启方式:`nohup`PID 文件托管)或 `launchd`(用 `launchctl kickstart` 交回 launchd 托管,避免与 KeepAlive 抢端口) |
| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 | | `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 |
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) | | `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
@@ -196,8 +233,9 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `up` | **一键部署**build + start + watchdog(强制重来),启动后等待就绪 |
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 | | `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
| `start` | 后台启动服务 | | `start` | 后台启动服务(强制重启并等待 `/api/health` 就绪;端口被非本项目进程占用会报错) |
| `stop` | 停止服务和 watchdog | | `stop` | 停止服务和 watchdog |
| `restart` | 重启服务 | | `restart` | 重启服务 |
| `status` | 查看服务和 watchdog 运行状态 | | `status` | 查看服务和 watchdog 运行状态 |
@@ -209,18 +247,17 @@ GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
### 常用操作 ### 常用操作
```bash ```bash
# 首次部署 # 首次部署(一键:build + start + watchdog
./deploy.sh build && ./deploy.sh start ./deploy.sh up
# 查看状态 # 查看状态
./deploy.sh status ./deploy.sh status
# 更新代码后重新部署 # 更新代码后重新部署
git pull git pull
./deploy.sh build ./deploy.sh up
./deploy.sh restart
# 启用健康监测 # 单独启用健康监测
./deploy.sh watchdog ./deploy.sh watchdog
# 停止所有服务 # 停止所有服务
@@ -252,7 +289,7 @@ logs/
| 角色 | 权限 | | 角色 | 权限 |
|------|------| |------|------|
| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) | | 管理员 | 所有功能 + 用户管理(增删改查、角色切换) |
| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) | | 普通用户 | 打包、查看历史、管理 Apps 配置(不可修改版本号及其余配置、不可管理用户) |
### 管理用户 ### 管理用户
@@ -370,6 +407,31 @@ launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist
--- ---
## 推送代码到 Git 服务器
本项目托管在自建 Git 服务(`origin` 走 HTTPS),推送需要账号凭据:
```bash
# 提交改动
git add -A
git commit -m "feat: 描述你的改动"
# 推送到远程 main
git push origin main
```
首次推送会提示输入用户名和密码/访问令牌。若不想每次都输入,可启用 macOS 钥匙串缓存凭据(只需配置一次):
```bash
git config --global credential.helper osxkeychain
```
配置后,下一次 `git push` 输入的账号密码会被安全保存到钥匙串,后续免输入。
> 若 Git 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
---
## 自动化测试 ## 自动化测试
### 运行测试 ### 运行测试
+5 -2
View File
@@ -105,6 +105,7 @@ COPY_ITEMS = [
"readoor.xcodeproj", "readoor.xcodeproj",
"readoorTests", "readoorTests",
"Vendor", "Vendor",
"AutoPacking",
"Podfile", "Podfile",
"Pods", "Pods",
"Podfile.lock", "Podfile.lock",
@@ -145,6 +146,8 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production") JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24 JWT_EXPIRE_HOURS = 24
# 受控 IPA 下载链接的 OSS 签名有效期(秒)。
DOWNLOAD_URL_EXPIRE_SECONDS = int(os.getenv("DOWNLOAD_URL_EXPIRE_SECONDS", "600"))
def validate_production_security() -> None: def validate_production_security() -> None:
@@ -157,8 +160,8 @@ def validate_production_security() -> None:
or JWT_SECRET.startswith("replace-") or len(JWT_SECRET) < 32): or JWT_SECRET.startswith("replace-") or len(JWT_SECRET) < 32):
errors.append("JWT_SECRET 必须设置为至少 32 位的随机字符串") errors.append("JWT_SECRET 必须设置为至少 32 位的随机字符串")
if (ADMIN_PASSWORD == "admin123" or ADMIN_PASSWORD.startswith("change-") if (ADMIN_PASSWORD == "admin123" or ADMIN_PASSWORD.startswith("change-")
or len(ADMIN_PASSWORD) < 12): or len(ADMIN_PASSWORD) < 11):
errors.append("ADMIN_PASSWORD 必须设置为至少 12 位的强密码") errors.append("ADMIN_PASSWORD 必须设置为至少 11 位的强密码")
if not CORS_ALLOWED_ORIGINS or "*" in CORS_ALLOWED_ORIGINS: if not CORS_ALLOWED_ORIGINS or "*" in CORS_ALLOWED_ORIGINS:
errors.append("CORS_ALLOWED_ORIGINS 必须设置为实际 HTTPS 前端域名,且不能为 *") errors.append("CORS_ALLOWED_ORIGINS 必须设置为实际 HTTPS 前端域名,且不能为 *")
if not os.getenv("TRUSTED_HOSTS") or "*" in TRUSTED_HOSTS: if not os.getenv("TRUSTED_HOSTS") or "*" in TRUSTED_HOSTS:
-455
View File
@@ -1,455 +0,0 @@
{
"apps": {
"100": {
"name": "阅门户测试App",
"server": "测试环境",
"AppGuid": "647372741900537856",
"API": "https://api3-dev.readoor.cn",
"AssDom": "applinks:dev-data1.readoor.cn",
"UniversalLink": "https://dev-data1.readoor.cn",
"weixinlogin": "wx30847bd529fe856c",
"weixinpay": "wx30847bd529fe856c",
"tencent": "tencent1105839866",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "0e6b269a",
"skins": [
"ymh.zip"
]
},
"101": {
"name": "申学",
"server": "测试环境",
"AppGuid": "571681541265068032",
"API": "https://api3-dev.readoor.cn",
"AssDom": "applinks:dev-data1.readoor.cn",
"UniversalLink": "https://dev-data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx30847bd529fe856c",
"weixinpay": "wx30847bd529fe856c",
"tencent": "tencent1105839866",
"certificates": {
"Ad_Hoc": {
"name": "com.kingsunsoft.CollectionBooks.Xinhua",
"pro": "TouchLinked_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "c843d672",
"skins": [
"ymh.zip"
]
},
"102": {
"name": "英汉大词典测试",
"server": "测试环境",
"AppGuid": "1593413622",
"API": "https://api3-dev.readoor.cn",
"certificates": {
"Ad_Hoc": {
"name": "com.yiwen.ecdstandalone",
"pro": "TouchLinked_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "weread"
}
},
"upload_key": "9b470281",
"skins": [
"weread.zip"
]
},
"103": {
"name": "英汉大词典",
"server": "正式环境",
"AppGuid": "1607584582",
"API": "https://api3.readoor.cn",
"certificates": {
"Ad_Hoc": {
"name": "com.yiwen.ecdstandalone",
"pro": "ecdstandalone_Adhoc",
"cer": "iPhone Distribution: Shanghai Translation Publishing House (8S29652W62)",
"theme": ""
},
"App_Store": {
"name": "com.yiwen.ecdstandalone",
"pro": "ecdstandalone_provisioning_profiles",
"cer": "iPhone Distribution: Shanghai Translation Publishing House (8S29652W62)",
"theme": ""
}
},
"upload_key": "2e743ca9"
},
"201": {
"name": "3.1版测试",
"server": "正式环境",
"AppGuid": "447445078487633920",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc1": {
"name": "cn.touchv.a0yny24",
"pro": "a0yny24_adhoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
},
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "2522c9bb",
"skins": [
"ymh.zip"
]
},
"202": {
"name": "北教辅学",
"server": "正式环境",
"AppGuid": "416182063440367616",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx1416817fa79df772",
"weixinpay": "wx1416817fa79df772",
"tencent": "",
"certificates": {
"App_Store": {
"name": "cn.touchv.adsATM4",
"pro": "beijiaofuxue",
"cer": "iPhone Distribution: Jingban Beijing Education Culture Media Corporation Limited (343SD396U6)",
"theme": "bj"
}
},
"upload_key": "ca03a41b",
"skins": [
"bj.zip"
]
},
"203": {
"name": "智慧云学",
"server": "正式环境",
"AppGuid": "576814720200249344",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "689c69a2",
"skins": [
"ymh.zip"
]
},
"204": {
"name": "北洋学堂",
"server": "正式环境",
"AppGuid": "559336679295455232",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx1df85bf317e35171",
"weixinpay": "wx1df85bf317e35171",
"tencent": "",
"certificates": {
"App_Store": {
"name": "cn.tjdxcbs.aBEM0B3",
"pro": "beiyangxuetang",
"cer": "iPhone Distribution: Tianjin University Press Co., Ltd.. (D3NDDP8CZU)",
"theme": "byxt"
}
},
"upload_key": "27fb0395",
"skins": [
"byxt.zip"
]
},
"205": {
"name": "交我学",
"server": "正式环境",
"AppGuid": "119822865246760960",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.a4YX061",
"pro": "a4YX061_adHoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "0718cd13",
"skins": [
"ymh.zip"
]
},
"206": {
"name": "上财云津",
"server": "正式环境",
"AppGuid": "389000208056356864",
"API": "https://api3.readoor.cn",
"AssDom": "applinks:data1.readoor.cn",
"UniversalLink": "https://data1.readoor.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx6f1163efcb202265",
"weixinpay": "wx6f1163efcb202265",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.shcj.aGh0UB2",
"pro": "aGh0UB2_adhoc",
"cer": "iPhone Distribution: Shanghai University of Finance and Economics Press Co., Ltd. (8S29L934WG)",
"theme": "shangcai"
},
"App_Store": {
"name": "cn.shcj.aGh0UB2",
"pro": "aGh0UB2_dis",
"cer": "iPhone Distribution: Shanghai University of Finance and Economics Press Co., Ltd. (8S29L934WG)",
"theme": "shangcai"
}
},
"upload_key": "8e326070",
"skins": [
"shangcai.zip"
]
},
"301": {
"name": "教育汇",
"server": "华师大环境",
"AppGuid": "152115765687226368",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "jPtBO1QJzkkHLu3F896a0569e78de46979050fb2605a6a2a5",
"weixinlogin": "wx9e23021f7f4313c0",
"weixinpay": "wx9e23021f7f4313c0",
"tencent": "tencent1105919518",
"certificates": {
"Ad_Hoc": {
"name": "com.ecnup.aSCLdl2",
"pro": "aSCLdl2_adhoc",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "hsd"
},
"App_Store": {
"name": "com.ecnup.aSCLdl2",
"pro": "com-ecnup-aSCLdl2_Distribution",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "hsd"
}
},
"upload_key": "f6fe827d",
"skins": [
"hsd.zip"
]
},
"302": {
"name": "教育汇测试",
"server": "华师大环境",
"AppGuid": "335068450529882112",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx9e23021f7f4313c0",
"weixinpay": "wx9e23021f7f4313c0",
"tencent": "tencent1105919518",
"certificates": {
"Ad_Hoc": {
"name": "com.ecnup.a4PBPk",
"pro": "a4PBPk_adhoc",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "hsd"
}
},
"upload_key": "4f9c7953",
"skins": [
"hsd.zip"
]
},
"303": {
"name": "大夏书系",
"server": "华师大环境",
"AppGuid": "639419713965142016",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "jPtBO1QJzkkHLu3F896a0569e78de46979050fb2605a6a2a5",
"weixinlogin": "wx1d95492fee2ce298",
"weixinpay": "wx1d95492fee2ce298",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "com.ecnup.aaS16d3",
"pro": "aaS16d3_adhoc",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "dxsx"
},
"App_Store": {
"name": "com.ecnup.aaS16d3",
"pro": "aaS16d3_dis",
"cer": "iPhone Distribution: East China Normal University Press Ltd (Q659D4XU6T)",
"theme": "dxsx"
}
},
"upload_key": "a29091d9",
"skins": [
"dxsx.zip"
]
},
"304": {
"name": "HSD测试App",
"server": "华师大环境",
"AppGuid": "470235916070694912",
"API": "https://api3.ecnupress.com.cn",
"AssDom": "applinks:data1.ecnupress.com.cn",
"UniversalLink": "https://data1.ecnupress.com.cn",
"AlivcLicenseKey": "",
"weixinlogin": "wx9e23021f7f4313c0",
"weixinpay": "wx9e23021f7f4313c0",
"tencent": "tencent1105919518",
"certificates": {
"Ad_Hoc": {
"name": "cn.touchv.aO3pOM",
"pro": "aO3pOM_adhoc",
"cer": "iPhone Distribution: Shanghai TouchLinked Info & Tech Co.,Ltd. (MG4Z7FU83W)",
"theme": "ymh"
}
},
"upload_key": "86bfb114",
"skins": [
"ymh.zip"
]
},
"401": {
"name": "WE Read",
"server": "外教环境",
"AppGuid": "631907986336071680",
"API": "https://weread-api3.sflep.com/api3",
"AssDom": "applinks:wereadossda.sflep.com",
"UniversalLink": "https://wereadossda.sflep.com",
"AlivcLicenseKey": "",
"weixinlogin": "wx6d6c63f4df7e5720",
"weixinpay": "",
"tencent": "",
"certificates": {
"Ad_Hoc": {
"name": "cn.sflep.aSiLjn",
"pro": "cn_sflep_aSiLjn_adhoc",
"cer": "iPhone Distribution: Shanghai Foreign Language Education Press Co.,Ltd. (G4X56AY2VB)",
"theme": "weread"
},
"App_Store": {
"name": "cn.sflep.aSiLjn",
"pro": "cn-sflep-aSiLjn",
"cer": "iPhone Distribution: Shanghai Foreign Language Education Press Co.,Ltd. (G4X56AY2VB)",
"theme": "weread"
}
},
"upload_key": "87e7656f",
"skins": [
"weread.zip"
]
}
},
"schemes": {
"1": {
"name": "readoor31",
"ossFloder": "readoor"
},
"2": {
"name": "readoor31OtherPay",
"ossFloder": "readoor"
},
"3": {
"name": "readoorShenXue",
"ossFloder": "readoor"
},
"4": {
"name": "readoorDict",
"ossFloder": "ecdict"
}
},
"servers": {
"测试环境": {
"api": "https://api3-dev.readoor.cn",
"assDom": "applinks:dev-data1.readoor.cn",
"universalLink": "https://dev-data1.readoor.cn"
},
"正式环境": {
"api": "https://api3.readoor.cn",
"assDom": "applinks:data1.readoor.cn",
"universalLink": "https://data1.readoor.cn"
},
"华师大环境": {
"api": "https://api3.ecnupress.com.cn",
"assDom": "applinks:data1.ecnupress.com.cn",
"universalLink": "https://data1.ecnupress.com.cn"
},
"外教环境": {
"api": "https://weread-api3.sflep.com/api3",
"assDom": "applinks:wereadossda.sflep.com",
"universalLink": "https://wereadossda.sflep.com"
}
},
"branches": [
"master",
"develop",
"feature"
],
"upload": {
"mode": "webdav",
"oss": {
"access_key_id": "",
"access_key_secret": "",
"endpoint": "oss-cn-beijing.aliyuncs.com",
"bucket_name": "",
"base_url": ""
},
"webdav": {
"server_url": "http://192.168.21.200:5005",
"username": "slcydia",
"password": "S9rr9359p:4o)nfel?W",
"base_path": "/APPBuildInfo",
"public_url": "http://192.168.21.200:5005/APPBuildInfo"
},
"dingtalk": {
"enabled": false,
"webhook_url": "",
"secret": ""
}
},
"versions": {
"app_ver": "2.195.0",
"build_ver": "2.195.0.0"
}
}
+1
View File
@@ -24,6 +24,7 @@ def _migrate_db():
migrations = [ migrations = [
("tasks", "branch", "VARCHAR DEFAULT 'main'"), ("tasks", "branch", "VARCHAR DEFAULT 'main'"),
("tasks", "error_category", "VARCHAR"), ("tasks", "error_category", "VARCHAR"),
("tasks", "superseded_at", "TIMESTAMP"),
] ]
with engine.connect() as conn: with engine.connect() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from .database import init_db from .database import init_db
from .routers import config, apps, tasks, auth, users from .routers import config, apps, tasks, auth, users, operations
from .services.log_streamer import log_streamer from .services.log_streamer import log_streamer
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user from .deps import decode_current_user
@@ -48,6 +48,7 @@ app.include_router(users.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(operations.router)
@app.websocket("/ws/tasks/{task_id}") @app.websocket("/ws/tasks/{task_id}")
+21
View File
@@ -25,6 +25,8 @@ class Task(Base):
created_at = Column(TIMESTAMP, default=datetime.utcnow) created_at = Column(TIMESTAMP, default=datetime.utcnow)
started_at = Column(TIMESTAMP) started_at = Column(TIMESTAMP)
completed_at = Column(TIMESTAMP) completed_at = Column(TIMESTAMP)
# 同一 OSS 对象被后续成功打包覆盖时标记;默认历史列表不展示。
superseded_at = Column(TIMESTAMP)
# 产物 # 产物
ipa_path = Column(String) ipa_path = Column(String)
@@ -60,3 +62,22 @@ class User(Base):
password_hash = Column(String, nullable=False) password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.utcnow) created_at = Column(TIMESTAMP, default=datetime.utcnow)
class OperationLog(Base):
"""用户关键操作的审计与统计记录。"""
__tablename__ = "operation_logs"
id = Column(String, primary_key=True)
created_at = Column(TIMESTAMP, default=datetime.utcnow, nullable=False)
username = Column(String, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
action = Column(String, nullable=False)
task_id = Column(String)
app_id = Column(String)
app_name = Column(String)
resource_type = Column(String)
resource_name = Column(String)
detail_json = Column(Text)
client_ip = Column(String)
user_agent = Column(String)
+341 -34
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
import shutil import shutil
import zipfile import zipfile
from datetime import datetime from datetime import datetime
@@ -25,7 +26,7 @@ from ..deps import get_current_user, require_admin
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict: def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户可管理 Apps;其余配置及凭据只允许管理员访问。""" """普通用户可管理 Apps版本号、其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/") path = request.url.path.rstrip("/")
if path == "/api/config/apps" or path.startswith("/api/config/apps/"): if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
return user return user
@@ -65,31 +66,228 @@ DEFAULT_SERVERS = {
"测试环境": { "测试环境": {
"api": "https://api3-dev.readoor.cn", "api": "https://api3-dev.readoor.cn",
"assDom": "applinks:dev-data1.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", "api": "https://api3.readoor.cn",
"assDom": "applinks:data1.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", "api": "https://api3.ecnupress.com.cn",
"assDom": "applinks:data1.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", "api": "https://weread-api3.sflep.com/api3",
"assDom": "applinks:wereadossda.sflep.com", "assDom": "applinks:wereadossda.sflep.com",
"universalLink": "https://wereadossda.sflep.com" "universalLink": "https://wereadossda.sflep.com",
"app_id_prefix": 4,
} }
} }
DEFAULT_VERSIONS = { DEFAULT_APP_VER = "2.180.0"
"app_ver": "2.180.0",
"build_ver": "2.180.0.0", # 版本轨道:上架分支(master/develop)共用 release,自测分支(feature/*)用 feature。
# 两条轨道的 App_Ver 各自独立填写,互不影响。
TRACK_RELEASE = "release"
TRACK_FEATURE = "feature"
TRACKS = (TRACK_RELEASE, TRACK_FEATURE)
# 按名字推断轨道的默认规则;config["branch_track"] 中的显式配置优先。
RELEASE_BRANCH_NAMES = {"master", "main", "develop", "dev"}
RELEASE_BRANCH_PREFIXES = ("release/", "release-", "hotfix/", "hotfix-")
def _default_track_for_branch(branch: str) -> str:
"""按分支名推断所属版本轨道。"""
name = (branch or "").strip().lower()
if name in RELEASE_BRANCH_NAMES or name.startswith(RELEASE_BRANCH_PREFIXES):
return TRACK_RELEASE
return TRACK_FEATURE
def _new_track(app_ver: str = DEFAULT_APP_VER) -> dict:
# 每个版本号(App_Ver)独立记录已用到的最大构建号第四位。
# App Store 的构建号唯一性按 MARKETING_VERSION(即完整 App_Ver) 分别计算,
# 因此必须按版本号分别记录,回退到旧版本号时才能从它自己的最大值继续递增。
return {"app_ver": app_ver, "build_map": {}}
def _ensure_versions(config: dict) -> bool:
"""确保 versions 为双轨结构,并迁移旧式单轨/单值 build_ver 配置。
旧式 ``{"app_ver": ..., "build_map": ...}`` 整体迁入 release 轨;
feature 轨用同一个 App_Ver 初始化,构建号从 .0 起且永不自增。
"""
versions = config.get("versions")
changed = False
if not isinstance(versions, dict):
config["versions"] = {"tracks": {TRACK_RELEASE: _new_track(), TRACK_FEATURE: _new_track()}}
return True
tracks = versions.get("tracks")
if not isinstance(tracks, dict):
# 旧式单轨结构 → release 轨
legacy_app_ver = versions.get("app_ver") or DEFAULT_APP_VER
legacy_build_map = versions.get("build_map")
if not isinstance(legacy_build_map, dict):
legacy_build_map = {}
# 更旧的单值形式 "2.195.0.3" → {"2.195.0": 3}
old = versions.get("build_ver")
if isinstance(old, str):
parts = old.split(".")
if len(parts) >= 4 and parts[3].isdigit():
legacy_build_map[".".join(parts[:3])] = int(parts[3])
versions.pop("app_ver", None)
versions.pop("build_map", None)
versions.pop("build_ver", None)
tracks = {
TRACK_RELEASE: {"app_ver": legacy_app_ver, "build_map": legacy_build_map},
TRACK_FEATURE: _new_track(legacy_app_ver),
}
versions["tracks"] = tracks
changed = True
for name in TRACKS:
track = tracks.get(name)
if not isinstance(track, dict):
tracks[name] = _new_track()
changed = True
continue
if not track.get("app_ver"):
track["app_ver"] = DEFAULT_APP_VER
changed = True
if not isinstance(track.get("build_map"), dict):
track["build_map"] = {}
changed = True
# 分支归属:只存显式配置,缺省按名字规则推断
branch_track = config.get("branch_track")
if not isinstance(branch_track, dict):
branch_track = {}
config["branch_track"] = branch_track
changed = True
for branch in config.get("branches", []) or []:
if branch_track.get(branch) not in TRACKS:
branch_track[branch] = _default_track_for_branch(branch)
changed = True
return changed
def get_track_name(config: dict, branch: str) -> str:
"""取分支所属的版本轨道名。"""
track = (config.get("branch_track") or {}).get(branch)
return track if track in TRACKS else _default_track_for_branch(branch)
def get_track(config: dict, track_name: str) -> dict:
"""取某条轨道的版本记录(不存在时返回默认值,不写回配置)。"""
tracks = (config.get("versions") or {}).get("tracks") or {}
track = tracks.get(track_name)
if not isinstance(track, dict):
return _new_track()
return track
def track_build_ver(track: dict) -> str:
"""该轨道当前 App_Ver 已用到的构建号,形如 2.195.0.5(尚未打包则 .0)。"""
app_ver = track.get("app_ver") or DEFAULT_APP_VER
build_map = track.get("build_map") or {}
return f"{app_ver}.{int(build_map.get(app_ver, 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 _normalize_tencent_app_id(app: dict) -> None:
"""腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)。"""
value = str(app.get("tencent") or "").strip()
if not value:
return
app["tencent"] = value if value.startswith("tencent") else f"tencent{value}"
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: def _ensure_upload_keys(config: dict) -> bool:
"""为没有 upload_key 的 app 自动生成,返回是否有变更""" """为没有 upload_key 的 app 自动生成,返回是否有变更"""
import uuid import uuid
@@ -150,7 +348,10 @@ def load_config() -> dict:
CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH) shutil.copy2(BOOTSTRAP_CONFIG_PATH, CONFIG_JSON_PATH)
else: 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: with open(CONFIG_JSON_PATH, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
# 确保必要字段存在 # 确保必要字段存在
@@ -160,10 +361,12 @@ def load_config() -> dict:
config["branches"] = ["main"] config["branches"] = ["main"]
if "upload" not in config: if "upload" not in config:
config["upload"] = DEFAULT_UPLOAD config["upload"] = DEFAULT_UPLOAD
if "versions" not in config: versions_changed = _ensure_versions(config)
config["versions"] = DEFAULT_VERSIONS.copy() # 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
# 自动为缺少 upload_key 的 app 生成唯一标识 prefixes_changed = _ensure_server_id_prefixes(config)
if _ensure_upload_keys(config): overrides_changed = _ensure_special_app_prefix_overrides(config)
keys_changed = _ensure_upload_keys(config)
if versions_changed or prefixes_changed or overrides_changed or keys_changed:
save_config(config) save_config(config)
# 迁移旧式目录皮肤为 ZIP # 迁移旧式目录皮肤为 ZIP
if _migrate_old_themes(config): if _migrate_old_themes(config):
@@ -215,9 +418,10 @@ async def create_app(app: dict):
config = load_config() config = load_config()
apps = config.get("apps", {}) apps = config.get("apps", {})
# 自动生成 ID # 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()] _apply_special_app_prefix_override(app)
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1" _normalize_tencent_app_id(app)
new_id = _next_app_id(apps, config.get("servers", {}), app)
# 自动生成 upload_key # 自动生成 upload_key
if not app.get("upload_key"): if not app.get("upload_key"):
@@ -226,7 +430,7 @@ async def create_app(app: dict):
apps[new_id] = app apps[new_id] = app
config["apps"] = apps config["apps"] = apps
save_config(config) save_config(config)
return {"id": new_id, "message": "App 已创建"} return {"id": new_id, "upload_key": app["upload_key"], "message": "App 已创建"}
@router.put("/apps/{app_id}") @router.put("/apps/{app_id}")
@@ -239,6 +443,7 @@ async def update_app(app_id: str, app: dict):
if app_id not in apps: if app_id not in apps:
raise HTTPException(status_code=404, detail="App 不存在") raise HTTPException(status_code=404, detail="App 不存在")
_normalize_tencent_app_id(app)
apps[app_id] = app apps[app_id] = app
config["apps"] = apps config["apps"] = apps
save_config(config) save_config(config)
@@ -320,6 +525,33 @@ async def upload_skin(upload_key: str, file: UploadFile = File(...)):
return {"message": "皮肤包上传成功", "name": file.filename} return {"message": "皮肤包上传成功", "name": file.filename}
@router.put("/skins/{upload_key}/{skin_name}")
async def replace_skin(upload_key: str, skin_name: str, file: UploadFile = File(...)):
"""替换已有皮肤包内容,保持原文件名不变。
APP 配置的 theme 通过文件名(去掉 .zip)引用皮肤包,若上传新文件名会新增一个皮肤而非更新,
导致原引用失效。此接口就地覆盖指定皮肤,确保被引用的皮肤可以更新。
"""
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
config = load_config()
app_id, _ = _find_app_by_upload_key(config, upload_key)
if not app_id:
raise HTTPException(status_code=404, detail="App 不存在")
app_skins_dir = SKINS_DIR / upload_key
skin_path = app_skins_dir / skin_name
if not skin_path.exists():
raise HTTPException(status_code=404, detail="皮肤包不存在")
content = await file.read()
with open(skin_path, "wb") as f:
f.write(content)
return {"message": "皮肤包已更新", "name": skin_name}
@router.delete("/skins/{upload_key}/{skin_name}") @router.delete("/skins/{upload_key}/{skin_name}")
async def delete_skin(upload_key: str, skin_name: str): async def delete_skin(upload_key: str, skin_name: str):
"""删除指定皮肤包""" """删除指定皮肤包"""
@@ -412,11 +644,15 @@ async def get_branches():
@router.post("/branches") @router.post("/branches")
async def add_branch(data: dict): async def add_branch(data: dict):
"""新增分支""" """新增分支,可指定所属版本轨道(缺省按分支名推断)"""
name = data.get("name", "").strip() name = data.get("name", "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="分支名称不能为空") raise HTTPException(status_code=400, detail="分支名称不能为空")
track = (data.get("track") or "").strip() or _default_track_for_branch(name)
if track not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
async with _config_lock: async with _config_lock:
config = load_config() config = load_config()
branches = config.get("branches", ["main"]) branches = config.get("branches", ["main"])
@@ -424,11 +660,32 @@ async def add_branch(data: dict):
raise HTTPException(status_code=400, detail="分支已存在") raise HTTPException(status_code=400, detail="分支已存在")
branches.append(name) branches.append(name)
config["branches"] = branches config["branches"] = branches
config.setdefault("branch_track", {})[name] = track
save_config(config) save_config(config)
return {"message": "分支已添加"} return {"message": "分支已添加", "track": track}
@router.delete("/branches/{branch_name}") @router.put("/branches/track")
async def update_branch_track(data: dict):
"""修改分支所属的版本轨道(release=上架,feature=自测)。
分支名可能含 /(如 feature/login),因此走请求体而非路径参数。
"""
branch = (data.get("branch") or "").strip()
track = (data.get("track") or "").strip()
if track not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track}")
async with _config_lock:
config = load_config()
if branch not in config.get("branches", []):
raise HTTPException(status_code=404, detail="分支不存在")
config.setdefault("branch_track", {})[branch] = track
save_config(config)
return {"message": "分支版本轨道已更新", "branch": branch, "track": track}
@router.delete("/branches/{branch_name:path}")
async def delete_branch(branch_name: str): async def delete_branch(branch_name: str):
"""删除分支""" """删除分支"""
async with _config_lock: async with _config_lock:
@@ -438,6 +695,7 @@ async def delete_branch(branch_name: str):
raise HTTPException(status_code=404, detail="分支不存在") raise HTTPException(status_code=404, detail="分支不存在")
branches.remove(branch_name) branches.remove(branch_name)
config["branches"] = branches config["branches"] = branches
config.get("branch_track", {}).pop(branch_name, None)
save_config(config) save_config(config)
return {"message": "分支已删除"} return {"message": "分支已删除"}
@@ -489,33 +747,78 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
# 版本号配置 API # 版本号配置 API
@router.get("/versions") @router.get("/versions")
async def get_versions(): async def get_versions():
"""获取服务端统一管理的 App_Ver 和 Build_Ver""" """获取两条版本轨道各自的 App_Ver 及其当前构建号。
build_ver 为该轨道当前 App_Ver 已用到的最大构建号(形如 2.195.0.5,尚未打包则 .0),
仅供展示;实际递增只发生在 release 轨的 App_Store 打包时,feature 轨永不自增。
顶层 app_ver / build_ver 为 release 轨的镜像,保留给旧客户端。
"""
config = load_config() config = load_config()
versions = config.get("versions", DEFAULT_VERSIONS.copy()) tracks = {}
for name in TRACKS:
track = get_track(config, name)
tracks[name] = {
"app_ver": track.get("app_ver") or DEFAULT_APP_VER,
"build_ver": track_build_ver(track),
"build_map": track.get("build_map") or {},
}
return { return {
"app_ver": versions.get("app_ver", DEFAULT_VERSIONS["app_ver"]), **tracks[TRACK_RELEASE],
"build_ver": versions.get("build_ver", DEFAULT_VERSIONS["build_ver"]), "tracks": tracks,
"branch_track": {
branch: get_track_name(config, branch)
for branch in config.get("branches", []) or []
},
} }
@router.put("/versions") @router.put("/versions")
async def update_versions(data: dict): async def update_versions(data: dict):
"""更新 App_Ver 和 Build_Ver""" """更新某条轨道的 App_Ver,并可选地手动设置该版本号的构建号。
每个 App_Ver 的构建号在所属轨道内独立保存于 build_map 各自递增,因此来回切换
版本号(包括回退到打过包的旧版本号)都不会重复,不会被 App Store 拒绝。
- trackreleasemaster/develop,上架用)或 feature(自测用),缺省 release。
- 不传 build_ver:保留该版本号已记录的构建号,仅更新该轨道的当前 App_Ver。
- 传 build_ver:形如 2.196.0.3,前三位须与 app_ver 一致,第四位为构建号;
直接写入该版本号的记录(可用于手动对齐 App Store Connect 上的已传构建号)。
"""
track_name = (data.get("track") or TRACK_RELEASE).strip()
if track_name not in TRACKS:
raise HTTPException(status_code=400, detail=f"未知的版本轨道: {track_name}")
app_ver = data.get("app_ver", "").strip() 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: build_ver = data.get("build_ver")
raise HTTPException(status_code=400, detail="版本号不能为空") build_no = None
if build_ver is not None and str(build_ver).strip():
build_ver = str(build_ver).strip()
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", build_ver):
raise HTTPException(status_code=400, detail="Build_Ver 必须为四段数字,例如 2.196.0.3")
prefix, _, last = build_ver.rpartition(".")
if prefix != app_ver:
raise HTTPException(status_code=400, detail=f"Build_Ver 前三位须与 App_Ver 一致(应为 {app_ver}.x")
build_no = int(last)
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
async with _config_lock: async with _config_lock:
config = load_config()
tracks = config.setdefault("versions", {}).setdefault("tracks", {})
track = tracks.setdefault(track_name, _new_track())
track["app_ver"] = app_ver
build_map = track.setdefault("build_map", {})
if build_no is not None:
build_map[app_ver] = build_no
save_config(config) save_config(config)
return {"message": "版本号已更新", "app_ver": app_ver, "build_ver": build_ver} return {
"message": "版本号已更新",
"track": track_name,
"app_ver": app_ver,
"build_ver": track_build_ver(track),
}
# 上传配置 API # 上传配置 API
@@ -557,12 +860,15 @@ async def create_server(server_data: dict):
if name in servers: if name in servers:
raise HTTPException(status_code=400, detail="环境名称已存在") raise HTTPException(status_code=400, detail="环境名称已存在")
prefix = config.get("next_app_id_prefix", 1)
servers[name] = { servers[name] = {
"api": server_data.get("api", ""), "api": server_data.get("api", ""),
"assDom": server_data.get("assDom", ""), "assDom": server_data.get("assDom", ""),
"universalLink": server_data.get("universalLink", ""), "universalLink": server_data.get("universalLink", ""),
"app_id_prefix": prefix,
} }
config["servers"] = servers config["servers"] = servers
config["next_app_id_prefix"] = prefix + 1
save_config(config) save_config(config)
return {"message": "服务器环境已创建"} return {"message": "服务器环境已创建"}
@@ -590,6 +896,7 @@ async def update_server(server_name: str, server_data: dict):
"api": server_data.get("api", ""), "api": server_data.get("api", ""),
"assDom": server_data.get("assDom", ""), "assDom": server_data.get("assDom", ""),
"universalLink": server_data.get("universalLink", ""), "universalLink": server_data.get("universalLink", ""),
"app_id_prefix": servers[new_name].get("app_id_prefix"),
} }
config["servers"] = servers config["servers"] = servers
save_config(config) save_config(config)
+65
View File
@@ -0,0 +1,65 @@
"""操作日志与统计 API(仅管理员)。"""
import csv
import io
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import OperationLog
router = APIRouter(prefix="/api/operations", tags=["operations"])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _query_logs(db: Session, days: int, username: str = "", action: str = ""):
since = datetime.utcnow() - timedelta(days=max(1, min(days, 365)))
query = db.query(OperationLog).filter(OperationLog.created_at >= since)
if username:
query = query.filter(OperationLog.username == username)
if action:
query = query.filter(OperationLog.action == action)
return query
@router.get("/summary")
async def operation_summary(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
query = _query_logs(db, days)
total = query.count()
action_counts = dict(query.with_entities(OperationLog.action, func.count()).group_by(OperationLog.action).all())
users = query.with_entities(OperationLog.username, func.count()).group_by(OperationLog.username).order_by(func.count().desc()).limit(10).all()
apps = query.filter(OperationLog.app_name.isnot(None)).with_entities(OperationLog.app_name, func.count()).group_by(OperationLog.app_name).order_by(func.count().desc()).limit(10).all()
return {"days": days, "total": total, "action_counts": action_counts,
"top_users": [{"name": name, "count": count} for name, count in users],
"top_apps": [{"name": name, "count": count} for name, count in apps]}
@router.get("")
async def list_operations(days: int = Query(30, ge=1, le=365), username: str = "", action: str = "", limit: int = Query(100, ge=1, le=500), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
logs = _query_logs(db, days, username, action).order_by(OperationLog.created_at.desc()).limit(limit).all()
return [{"id": log.id, "created_at": log.created_at, "username": log.username,
"action": log.action, "app_name": log.app_name, "task_id": log.task_id,
"resource_type": log.resource_type, "resource_name": log.resource_name,
"detail": json.loads(log.detail_json or "{}"), "client_ip": log.client_ip} for log in logs]
@router.get("/export")
async def export_operations(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["时间", "用户", "操作", "App", "资源类型", "资源名称", "IP", "详情"])
for log in _query_logs(db, days).order_by(OperationLog.created_at.desc()).all():
writer.writerow([log.created_at, log.username, log.action, log.app_name or "", log.resource_type or "", log.resource_name or "", log.client_ip or "", log.detail_json or ""])
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=operation-logs.csv"})
+83 -7
View File
@@ -1,11 +1,12 @@
"""任务 API""" """任务 API"""
import os import os
import json
import shutil import shutil
import tempfile import tempfile
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,7 +20,7 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(ge
@router.post("", response_model=TaskResponse) @router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, db: Session = Depends(get_db)): async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建打包任务""" """创建打包任务"""
from .config import load_config from .config import load_config
@@ -62,6 +63,9 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
status="pending", status="pending",
) )
db.add(db_task) db.add(db_task)
from ..services.audit_log import record_operation
record_operation(db, user, "build_created", request=request, task=db_task,
detail={"branch": task.branch, "build_type": task.build_type})
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
@@ -99,12 +103,19 @@ def _enrich_tasks(tasks):
@router.get("", response_model=List[TaskResponse]) @router.get("", response_model=List[TaskResponse])
async def list_tasks( async def list_tasks(
status: str = None, status: str = None,
include_superseded: bool = False,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
user: dict = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取任务列表""" """获取任务列表"""
query = db.query(Task) query = db.query(Task)
if include_superseded:
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
else:
query = query.filter(Task.superseded_at.is_(None))
if status: if status:
query = query.filter(Task.status == status) query = query.filter(Task.status == status)
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
@@ -121,7 +132,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
@router.delete("/{task_id}") @router.delete("/{task_id}")
async def cancel_task(task_id: str, db: Session = Depends(get_db)): async def cancel_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""取消任务""" """取消任务"""
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
@@ -131,6 +142,8 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="任务已完成或已取消") raise HTTPException(status_code=400, detail="任务已完成或已取消")
task.status = "cancelled" task.status = "cancelled"
from ..services.audit_log import record_operation
record_operation(db, user, "build_cancelled", request=request, task=task)
db.commit() db.commit()
# 尝试取消运行中的任务 # 尝试取消运行中的任务
@@ -152,7 +165,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
@router.delete("/{task_id}/delete") @router.delete("/{task_id}/delete")
async def delete_task(task_id: str, db: Session = Depends(get_db)): async def delete_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除打包记录""" """删除打包记录"""
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -164,6 +177,29 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if task.status in ("running", "pending"): if task.status in ("running", "pending"):
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除") 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" log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
if log_path.exists(): if log_path.exists():
@@ -175,10 +211,15 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if build_path.exists(): if build_path.exists():
shutil.rmtree(build_path, ignore_errors=True) shutil.rmtree(build_path, ignore_errors=True)
from ..services.audit_log import record_operation
record_operation(db, user, "task_deleted", request=request, task=task)
db.delete(task) db.delete(task)
db.commit() db.commit()
return {"message": "记录已删除"} message = "记录已删除"
if task.oss_url:
message += "(远端产物已删除)" if not has_shared_artifact else "(远端产物仍被其他记录引用,未删除)"
return {"message": message}
@router.get("/{task_id}/log") @router.get("/{task_id}/log")
@@ -216,8 +257,35 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
return {"log": ""} return {"log": ""}
@router.get("/{task_id}/download-link")
async def get_download_link(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""记录已登录用户发起 IPA 下载,并返回 OSS 公开链接。"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.oss_url:
raise HTTPException(status_code=404, detail="下载文件不存在")
try:
from .config import load_config
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
from ..services.distribution import DistributionError, get_published_download_url
download_url = get_published_download_url(
json.loads(task.config_json or "{}"),
load_config().get("upload", {}),
task.oss_url,
)
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task,
resource_type="ipa", resource_name=task.oss_url,
detail={"controlled": True})
db.commit()
return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS}
@router.get("/{task_id}/dsym") @router.get("/{task_id}/dsym")
async def download_dsym(task_id: str, db: Session = Depends(get_db)): async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载 dSYM 文件""" """下载 dSYM 文件"""
import shutil import shutil
import zipfile import zipfile
@@ -228,6 +296,10 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path: if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在") raise HTTPException(status_code=404, detail="dSYM 文件不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
db.commit()
dsym_path = task.dsym_path dsym_path = task.dsym_path
if not os.path.exists(dsym_path): if not os.path.exists(dsym_path):
@@ -248,13 +320,17 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
@router.get("/{task_id}/obfuscation-maps") @router.get("/{task_id}/obfuscation-maps")
async def download_obfuscation_maps(task_id: str, db: Session = Depends(get_db)): async def download_obfuscation_maps(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载混淆映射表""" """下载混淆映射表"""
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if not task.obfuscation_maps_path: if not task.obfuscation_maps_path:
raise HTTPException(status_code=404, detail="混淆映射表不存在") raise HTTPException(status_code=404, detail="混淆映射表不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "obfuscation_maps_downloaded", request=request, task=task,
resource_type="obfuscation_maps", resource_name=os.path.basename(task.obfuscation_maps_path))
db.commit()
maps_path = task.obfuscation_maps_path maps_path = task.obfuscation_maps_path
if not os.path.exists(maps_path): if not os.path.exists(maps_path):
+4 -4
View File
@@ -35,8 +35,8 @@ async def create_user(data: dict, user: dict = Depends(get_current_user), db: Se
if not username or not password: if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空") raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 12: if len(password) < 11:
raise HTTPException(status_code=400, detail="密码至少 12") raise HTTPException(status_code=400, detail="密码至少 11")
if db.query(User).filter(User.username == username).first(): if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在") raise HTTPException(status_code=400, detail="用户名已存在")
@@ -61,8 +61,8 @@ async def change_password(user_id: str, data: dict, user: dict = Depends(get_cur
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip() new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 12: if not new_password or len(new_password) < 11:
raise HTTPException(status_code=400, detail="密码至少 12") raise HTTPException(status_code=400, detail="密码至少 11")
target.password_hash = hash_password(new_password) target.password_hash = hash_password(new_password)
db.commit() db.commit()
+1
View File
@@ -27,6 +27,7 @@ class TaskResponse(BaseModel):
created_at: Optional[datetime] created_at: Optional[datetime]
started_at: Optional[datetime] started_at: Optional[datetime]
completed_at: Optional[datetime] completed_at: Optional[datetime]
superseded_at: Optional[datetime] = None
ipa_path: Optional[str] ipa_path: Optional[str]
oss_url: Optional[str] oss_url: Optional[str]
dsym_path: Optional[str] dsym_path: Optional[str]
+23
View File
@@ -0,0 +1,23 @@
"""操作审计日志。"""
import json
import uuid
from datetime import datetime
from ..models import OperationLog
def record_operation(db, user: dict, action: str, *, request=None, task=None,
resource_type: str = "", resource_name: str = "", detail: dict | None = None):
"""将已登录用户的关键操作写入当前数据库事务。"""
client_ip = request.client.host if request and request.client else ""
user_agent = request.headers.get("user-agent", "")[:500] if request else ""
db.add(OperationLog(
id=str(uuid.uuid4()), created_at=datetime.utcnow(), username=user.get("username", ""),
is_admin=bool(user.get("is_admin")), action=action,
task_id=task.id if task else None,
app_id=task.app_id if task else None,
app_name=task.app_name if task else None,
resource_type=resource_type, resource_name=resource_name,
detail_json=json.dumps(detail or {}, ensure_ascii=False),
client_ip=client_ip, user_agent=user_agent,
))
+97 -20
View File
@@ -6,7 +6,7 @@ import plistlib
import shutil import shutil
import subprocess import subprocess
import zipfile import zipfile
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@@ -24,6 +24,7 @@ from ..config import (
) )
from .log_streamer import log_streamer from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa from .distribution import DistributionError, publish_ipa
from .notification import NotificationError, send_dingtalk_notification
from .project_patcher import ProjectPatchError, apply_project_config from .project_patcher import ProjectPatchError, apply_project_config
@@ -213,6 +214,7 @@ def _cleanup_old_builds(db):
base = BUILD_BASE_DIR base = BUILD_BASE_DIR
if not base.exists(): if not base.exists():
_cleanup_superseded_tasks(db)
return return
for d in base.iterdir(): for d in base.iterdir():
if d.is_dir() and d.name.startswith("build_readoor_"): if d.is_dir() and d.name.startswith("build_readoor_"):
@@ -221,6 +223,50 @@ def _cleanup_old_builds(db):
shutil.rmtree(d) shutil.rmtree(d)
except Exception: except Exception:
pass pass
_cleanup_superseded_tasks(db)
def _cleanup_superseded_tasks(db):
"""7 天后删除已被新 OSS 包覆盖的历史记录及仅本地保留的文件。"""
from ..models import Task
cutoff = datetime.utcnow() - timedelta(days=7)
tasks = db.query(Task).filter(
Task.superseded_at.isnot(None), Task.superseded_at < cutoff,
).all()
for task in tasks:
for path_value in (task.build_dir, task.dsym_path, task.obfuscation_maps_path):
if not path_value:
continue
try:
path = Path(path_value)
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.exists():
path.unlink()
except OSError:
pass
log_path = Path(__file__).parent.parent / "logs" / f"{task.id}.log"
try:
log_path.unlink(missing_ok=True)
except OSError:
pass
db.delete(task)
if tasks:
db.commit()
def _mark_superseded_tasks(db, latest_task):
"""标记同一 OSS 对象的旧成功记录;OSS 文件由最新任务覆盖。"""
if not latest_task.oss_url:
return
from ..models import Task
db.query(Task).filter(
Task.id != latest_task.id,
Task.status == "completed",
Task.oss_url == latest_task.oss_url,
Task.superseded_at.is_(None),
).update({Task.superseded_at: datetime.utcnow()}, synchronize_session=False)
db.commit()
def _cleanup_old_logs(): def _cleanup_old_logs():
@@ -438,12 +484,30 @@ async def run_build_task(task_id: str):
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(), status="completed", completed_at=datetime.utcnow(),
current_step=None) current_step=None)
await asyncio.to_thread(_mark_superseded_tasks, db, task)
await log_streamer.emit_step(task_id, "打包完成") await log_streamer.emit_step(task_id, "打包完成")
await log_streamer.emit(task_id, f"IPA: {ipa_path}") await log_streamer.emit(task_id, f"IPA: {ipa_path}")
if task.oss_url: if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {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) await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
@@ -537,35 +601,44 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
"""生成打包配置""" """生成打包配置"""
await log_streamer.emit_step(task_id, "生成配置") await log_streamer.emit_step(task_id, "生成配置")
from ..routers.config import load_config, _config_lock from ..routers.config import (
load_config, _config_lock, get_track, get_track_name, track_build_ver,
TRACK_RELEASE, _new_track,
)
config = load_config() config = load_config()
apps = config.get("apps", {}) apps = config.get("apps", {})
schemes = config.get("schemes", {}) schemes = config.get("schemes", {})
versions = config.get("versions", {})
app = apps.get(task.app_id, {}) app = apps.get(task.app_id, {})
scheme = schemes.get(task.scheme_id, {}) scheme = schemes.get(task.scheme_id, {})
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。 # 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
app_ver = versions.get("app_ver", "2.180.0") # 按分支所属轨道取版本号:master/develop 走 release 轨(上架用),feature 走 feature 轨(自测用),
build_ver = versions.get("build_ver", "2.180.0.0") # 两条轨道的 App_Ver 各自独立。
track_name = get_track_name(config, task.branch)
track = get_track(config, track_name)
app_ver = track.get("app_ver") or "2.180.0"
# App_Store 打包时自动递增 Build_Ver 第四位版本号 # 构建号按版本号独立记录:build_map[app_ver] 为该版本号已用到的最大第四位。
if task.build_type == "App_Store": build_ver = track_build_ver(track)
await log_streamer.emit(
task_id,
f"版本轨道: {track_name}{'上架,App_Store 打包自增' if track_name == TRACK_RELEASE else '自测,构建号不自增'}",
)
# release 轨的 App_Store 打包,在该版本号自己的构建号上 +1
# (回退到旧版本号也从它自己的最大值继续);feature 轨始终使用手动设定的构建号。
if track_name == TRACK_RELEASE and task.build_type == "App_Store":
async with _config_lock: async with _config_lock:
parts = build_ver.split(".")
if len(parts) >= 4:
parts[3] = str(int(parts[3]) + 1)
else:
parts.append("1")
build_ver = ".".join(parts)
config = load_config() config = load_config()
config["versions"] = { tracks = config.setdefault("versions", {}).setdefault("tracks", {})
"app_ver": app_ver, track = tracks.setdefault(track_name, _new_track(app_ver))
"build_ver": build_ver, build_map = track.setdefault("build_map", {})
} n = int(build_map.get(app_ver, 0)) + 1
build_map[app_ver] = n
build_ver = f"{app_ver}.{n}"
from ..routers.config import save_config from ..routers.config import save_config
save_config(config) save_config(config)
@@ -611,7 +684,12 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
skin_extract_dir.mkdir(exist_ok=True) skin_extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(skin_zip, "r") as zf: with zipfile.ZipFile(skin_zip, "r") as zf:
zf.extractall(skin_extract_dir) zf.extractall(skin_extract_dir)
entries = list(skin_extract_dir.iterdir()) # 过滤掉 macOS 压缩产生的 __MACOSX 及隐藏文件,避免把它们
# 当成皮肤资源目录一起复制进 Images.xcassets。
entries = [
e for e in skin_extract_dir.iterdir()
if e.name != "__MACOSX" and not e.name.startswith(".")
]
if len(entries) == 1 and entries[0].is_dir(): if len(entries) == 1 and entries[0].is_dir():
config_data["THEME"] = str(entries[0]) config_data["THEME"] = str(entries[0])
else: else:
@@ -787,7 +865,6 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
"-archivePath", str(archive_path), "-archivePath", str(archive_path),
"-derivedDataPath", str(export_path / "derived_data"), "-derivedDataPath", str(export_path / "derived_data"),
"-destination", "generic/platform=ios", "-destination", "generic/platform=ios",
"-quiet",
cwd=str(build_dir), cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
+131 -6
View File
@@ -2,12 +2,15 @@
import json import json
import plistlib import plistlib
import posixpath import posixpath
import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote, unquote, urlparse
import httpx import httpx
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
class DistributionError(Exception): class DistributionError(Exception):
"""分发配置或上传过程失败。""" """分发配置或上传过程失败。"""
@@ -15,7 +18,12 @@ class DistributionError(Exception):
def _artifact_stem(config: dict) -> str: def _artifact_stem(config: dict) -> str:
version = config.get("VERSION", "0").replace(".", "_") 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]: def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
@@ -58,7 +66,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) stem = _artifact_stem(config)
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds" folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
root = posixpath.join(folder, "iOS") root = posixpath.join(folder, "iOS")
@@ -66,9 +74,125 @@ def _remote_paths(config: dict) -> tuple[str, str, str]:
posixpath.join(root, f"{stem}.ipa"), posixpath.join(root, f"{stem}.ipa"),
posixpath.join(root, f"{stem}.plist"), posixpath.join(root, f"{stem}.plist"),
posixpath.join(root, f"{stem}.html"), 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 get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
if upload_config.get("mode") != "oss":
return published_url
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
if not remote_paths:
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
oss = upload_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,
)
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str): def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
path = "" path = ""
for segment in remote_path.strip("/").split("/")[:-1]: for segment in remote_path.strip("/").split("/")[:-1]:
@@ -127,7 +251,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]: def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
"""发布 IPA,返回下载 URL 与二维码本地路径""" """发布 IPA,返回下载 URL 与二维码公开 URL"""
upload = config.get("_upload_config", {}) upload = config.get("_upload_config", {})
mode = upload.get("mode", "") mode = upload.get("mode", "")
if mode not in {"oss", "webdav"}: if mode not in {"oss", "webdav"}:
@@ -135,7 +259,7 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
output_dir = build_dir / "distribution" output_dir = build_dir / "distribution"
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir) 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)
uploader = _upload_oss if mode == "oss" else _upload_webdav uploader = _upload_oss if mode == "oss" else _upload_webdav
@@ -157,4 +281,5 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
except ImportError as exc: except ImportError as exc:
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
qrcode.make(urls[".html"]).save(qr_path) 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
+3
View File
@@ -68,6 +68,9 @@ def _apply_theme(build_dir: Path, theme_dir: str, messages: list[str]):
for item in source.iterdir(): for item in source.iterdir():
if not item.is_dir(): if not item.is_dir():
continue continue
# 跳过 macOS 压缩包内的 __MACOSX 及隐藏目录,仅复制真实皮肤资源
if item.name == "__MACOSX" or item.name.startswith("."):
continue
destination = target_assets / item.name destination = target_assets / item.name
if destination.exists(): if destination.exists():
shutil.rmtree(destination) shutil.rmtree(destination)
+79
View File
@@ -0,0 +1,79 @@
{
"apps": {
"1": {
"name": "示例应用",
"server": "测试环境",
"API": "https://api3-dev.example.com",
"AppGuid": "00000000-0000-0000-0000-000000000000",
"AssDom": "applinks:dev-data1.example.com",
"UniversalLink": "https://dev-data1.example.com",
"upload_key": "example_app",
"tencent": "",
"weixinlogin": "",
"weixinpay": "",
"skins": ["default.zip"],
"certificates": {
"Ad_Hoc": {
"name": "com.example.app",
"cer": "证书名称或标识",
"pro": "描述文件名称",
"theme": "default"
},
"App_Store": {
"name": "com.example.app",
"cer": "证书名称或标识",
"pro": "描述文件名称",
"theme": "default"
}
}
}
},
"schemes": {
"1": { "name": "readoor31", "ossFloder": "readoor" }
},
"servers": {
"测试环境": {
"api": "https://api3-dev.example.com",
"assDom": "applinks:dev-data1.example.com",
"universalLink": "https://dev-data1.example.com",
"app_id_prefix": 1
},
"正式环境": {
"api": "https://api3.example.com",
"assDom": "applinks:data1.example.com",
"universalLink": "https://data1.example.com",
"app_id_prefix": 2
}
},
"next_app_id_prefix": 3,
"branches": ["master", "develop"],
"upload": {
"mode": "webdav",
"oss": {
"access_key_id": "",
"access_key_secret": "",
"endpoint": "",
"bucket_name": "",
"base_url": ""
},
"webdav": {
"server_url": "",
"username": "",
"password": "",
"base_path": "",
"public_url": ""
},
"dingtalk": {
"enabled": false,
"webhook_url": "",
"secret": ""
}
},
"versions": {
"tracks": {
"release": { "app_ver": "2.180.0", "build_map": {} },
"feature": { "app_ver": "2.180.0", "build_map": {} }
}
},
"branch_track": { "master": "release", "develop": "release" }
}
+121 -50
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# iOS 自动打包服务 — 部署与管理脚本 # iOS 自动打包服务 — 部署与管理脚本
# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop} # 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop}
set -e set -e
@@ -23,6 +23,11 @@ WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒) WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却 MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒) COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒)
WATCHDOG_STARTUP_GRACE="${WATCHDOG_STARTUP_GRACE:-10}" # watchdog 首次检查前的宽限(秒),避开正常启动窗口
STARTUP_READY_TIMEOUT="${STARTUP_READY_TIMEOUT:-20}" # 启动后等待 /api/health 就绪的最长时间(秒)
# 重启方式:nohup(默认,PID 文件托管)| launchd(交回 launchctl kickstart 托管,避免与 KeepAlive 抢端口)
WATCHDOG_RESTART_MODE="${WATCHDOG_RESTART_MODE:-nohup}"
APP_LAUNCHD_LABEL="${APP_LAUNCHD_LABEL:-com.readoor.buildserver}"
# ========== 颜色输出 ========== # ========== 颜色输出 ==========
RED='\033[0;31m' RED='\033[0;31m'
@@ -64,6 +69,52 @@ get_server_pid() {
return 1 return 1
} }
# 返回占用目标端口、但命令不属于本项目的进程 PID(若有),用于区分“端口冲突”与“本服务在跑”。
foreign_port_holder() {
local pid
for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do
if ! is_server_process "$pid"; then
echo "$pid"
return 0
fi
done
return 1
}
# 启动 uvicorn 后端进程(单点维护启动参数,供 start / watchdog 复用),PID 写入 PID_FILE。
_spawn_server() {
setup_venv
mkdir -p "$LOG_DIR"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--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"
}
# 轮询 /api/health 直到返回 200 或超时;就绪返回 0,否则返回 1。
# 健康检查固定走 127.0.0.1(与 check_health 一致),不受 BIND_HOST 影响。
_wait_ready() {
local timeout="${1:-$STARTUP_READY_TIMEOUT}"
local deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "$deadline" ]; do
# 进程若已退出则无需再等
if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
return 1
fi
local code
code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 2 --max-time 3 \
"http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000")
[ "$code" = "200" ] && return 0
sleep 1
done
return 1
}
# 日志同时写文件和终端 # 日志同时写文件和终端
log_to_file() { log_to_file() {
local level="$1"; shift local level="$1"; shift
@@ -143,11 +194,23 @@ do_build() {
do_start() { do_start() {
check_env check_env
# 端口被非本项目进程占用:明确报错,避免 uvicorn 静默绑定失败。
local foreign
foreign=$(foreign_port_holder || true)
if [ -n "$foreign" ]; then
error "端口 $BACKEND_PORT 被非本项目进程占用 (PID: $foreign):"
ps -p "$foreign" -o pid,command= 2>/dev/null | sed 's/^/ /'
error "请先释放该端口,或在 .env 修改 BACKEND_PORT"
return 1
fi
# 本项目实例已在运行:强制重来(用户选定语义)。
# 注意:这会中断正在进行的打包任务。
local existing_pid local existing_pid
existing_pid=$(get_server_pid || true) existing_pid=$(get_server_pid || true)
if [ -n "$existing_pid" ]; then if [ -n "$existing_pid" ]; then
warn "服务已在运行 (PID: $existing_pid)" warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..."
return 0 do_stop
fi fi
# 确保前端已构建 # 确保前端已构建
@@ -156,28 +219,15 @@ do_start() {
do_build do_build
fi fi
setup_venv
mkdir -p "$LOG_DIR"
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..." info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ _spawn_server
--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" if _wait_ready; then
sleep 1 info "服务已就绪 (PID: $(cat "$PID_FILE"))"
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
info "服务已启动 (PID: $(cat "$PID_FILE"))"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT" info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else else
error "启动失败,请查看日志: $LOG_DIR/server.log" error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log"
rm -f "$PID_FILE" tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /'
return 1 return 1
fi fi
} }
@@ -252,12 +302,15 @@ check_health() {
# ========== Watchdog(前台模式,用于调试) ========== # ========== Watchdog(前台模式,用于调试) ==========
do_watchdog_fg() { do_watchdog_fg() {
info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s" info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s"
info "按 Ctrl+C 停止" info "按 Ctrl+C 停止"
local restart_count=0 local restart_count=0
local last_restart_time=0 local last_restart_time=0
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do while true; do
local result local result
result=$(check_health) result=$(check_health)
@@ -338,7 +391,10 @@ do_watchdog_loop() {
local restart_count=0 local restart_count=0
local last_restart_time=0 local last_restart_time=0
log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s" log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s, grace=${WATCHDOG_STARTUP_GRACE}s, mode=${WATCHDOG_RESTART_MODE}"
# 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。
sleep "$WATCHDOG_STARTUP_GRACE"
while true; do while true; do
local result local result
@@ -366,35 +422,33 @@ do_watchdog_loop() {
restart_count=$((restart_count + 1)) restart_count=$((restart_count + 1))
last_restart_time=$(date +%s) last_restart_time=$(date +%s)
log_to_file "WARN" "Restarting service (attempt: $restart_count)" log_to_file "WARN" "Restarting service (attempt: $restart_count, mode: $WATCHDOG_RESTART_MODE)"
# 停止旧进程 if [ "$WATCHDOG_RESTART_MODE" = "launchd" ]; then
if [ -f "$PID_FILE" ]; then # launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。
local old_pid if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then
old_pid=$(cat "$PID_FILE") log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL"
kill "$old_pid" 2>/dev/null else
sleep 2 log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL"
kill -9 "$old_pid" 2>/dev/null || true fi
rm -f "$PID_FILE" else
# nohup 托管模式:停掉旧进程后重新拉起。
if [ -f "$PID_FILE" ]; then
local old_pid
old_pid=$(cat "$PID_FILE")
kill "$old_pid" 2>/dev/null
sleep 2
kill -9 "$old_pid" 2>/dev/null || true
rm -f "$PID_FILE"
fi
_spawn_server
fi fi
# 重新启动 # 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。
setup_venv if _wait_ready; then
mkdir -p "$LOG_DIR" log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))"
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
--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"
sleep 3
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))"
else else
log_to_file "ERROR" "Service restart failed" log_to_file "ERROR" "Service restart failed or not ready in ${STARTUP_READY_TIMEOUT}s"
fi fi
fi fi
@@ -457,8 +511,21 @@ do_test() {
fi fi
} }
# ========== 一键部署(强制重来)==========
# build → start(强制重启并等待就绪)→ watchdog,最后打印状态。
do_up() {
info "一键部署(强制重来):build → start → watchdog"
do_watchdog_stop >/dev/null 2>&1 || true # 先停监听,避免它在重启窗口内干预
do_build
do_start # do_start 内部会强制停旧服务并等待 health 就绪
do_watchdog
echo ""
do_status
}
# ========== 主入口 ========== # ========== 主入口 ==========
case "${1:-}" in case "${1:-}" in
up) do_up ;;
build) do_build ;; build) do_build ;;
start) do_start ;; start) do_start ;;
stop) do_stop; do_watchdog_stop ;; stop) do_stop; do_watchdog_stop ;;
@@ -470,10 +537,11 @@ case "${1:-}" in
watchdog-fg) do_watchdog_fg ;; watchdog-fg) do_watchdog_fg ;;
_watchdog_loop) do_watchdog_loop ;; _watchdog_loop) do_watchdog_loop ;;
*) *)
echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}" echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}"
echo "" echo ""
echo " up 一键部署:build + start + watchdog(强制重来)"
echo " build 构建前端、安装依赖" echo " build 构建前端、安装依赖"
echo " start 启动服务(后台运行" echo " start 启动服务(强制重启并等待就绪;端口被占用会报错"
echo " stop 停止服务(同时停止 watchdog" echo " stop 停止服务(同时停止 watchdog"
echo " restart 重启服务" echo " restart 重启服务"
echo " status 查看运行状态" echo " status 查看运行状态"
@@ -485,6 +553,9 @@ case "${1:-}" in
echo "环境变量(可在 .env 中配置):" echo "环境变量(可在 .env 中配置):"
echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)" echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)"
echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)" echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)"
echo " WATCHDOG_STARTUP_GRACE watchdog 首次检查前宽限(秒,默认 10)"
echo " STARTUP_READY_TIMEOUT 启动后等待 health 就绪的最长时间(秒,默认 20)"
echo " WATCHDOG_RESTART_MODE 重启方式 nohup|launchd(默认 nohup"
echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3" echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3"
echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)" echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)"
exit 1 exit 1
+2
View File
@@ -95,6 +95,8 @@ EOF
<dict> <dict>
<key>PATH</key> <key>PATH</key>
<string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> <string>$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>WATCHDOG_RESTART_MODE</key>
<string>launchd</string>
</dict> </dict>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
+1
View File
@@ -7,6 +7,7 @@
<router-link to="/build">打包</router-link> <router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link> <router-link to="/history">历史</router-link>
<router-link to="/admin">管理</router-link> <router-link to="/admin">管理</router-link>
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
</nav> </nav>
<div v-if="!isLoggedIn" class="user-info"> <div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button> <button class="btn-login" @click="showLogin = true">登录</button>
+37 -14
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import BuildView from '../views/BuildView.vue' import BuildView from '../views/BuildView.vue'
global.fetch = vi.fn() global.fetch = vi.fn()
@@ -11,12 +12,25 @@ class MockWebSocket {
} }
global.WebSocket = 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', () => { describe('BuildView.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
fetch.mockImplementation((url) => { fetch.mockImplementation((url) => {
const responses = { const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境' } }, '/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } }, '/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'], '/api/branches': ['main', 'dev'],
'/api/tasks': [], '/api/tasks': [],
@@ -28,7 +42,7 @@ describe('BuildView.vue', () => {
}) })
it('显示打包配置面板', async () => { it('显示打包配置面板', async () => {
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('打包配置') expect(wrapper.text()).toContain('打包配置')
@@ -39,15 +53,17 @@ describe('BuildView.vue', () => {
}) })
it('加载 apps 和 schemes', async () => { it('加载 apps 和 schemes', async () => {
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() 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) expect(appOptions.length).toBeGreaterThan(1)
}) })
it('加载分支列表并显示下拉', async () => { it('加载分支列表并显示下拉', async () => {
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() await flushPromises()
const branchSelect = wrapper.findAll('select').find(s => { const branchSelect = wrapper.findAll('select').find(s => {
@@ -58,10 +74,10 @@ describe('BuildView.vue', () => {
}) })
it('默认值正确', async () => { it('默认值正确', async () => {
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() 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.obfuscation).toBe(false)
expect(wrapper.vm.form.branch).toBe('main') expect(wrapper.vm.form.branch).toBe('main')
}) })
@@ -75,7 +91,7 @@ describe('BuildView.vue', () => {
}) })
} }
const responses = { const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境' } }, '/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } }, '/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'], '/api/branches': ['main', 'dev'],
'/api/tasks': [], '/api/tasks': [],
@@ -85,21 +101,28 @@ describe('BuildView.vue', () => {
}) })
}) })
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() 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 wrapper.find('.btn-primary').trigger('click')
await flushPromises() await flushPromises()
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
method: 'POST', 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 () => { it('未选择 App 时提示', async () => {
window.alert = vi.fn() window.alert = vi.fn()
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() await flushPromises()
await wrapper.find('.btn-primary').trigger('click') await wrapper.find('.btn-primary').trigger('click')
@@ -109,7 +132,7 @@ describe('BuildView.vue', () => {
it('显示任务队列', async () => { it('显示任务队列', async () => {
fetch.mockImplementation((url) => { fetch.mockImplementation((url) => {
const responses = { const responses = {
'/api/apps': { '1': { name: '测试App', server: '测试环境' } }, '/api/apps': { '1': { name: '测试App', server: '测试环境', certificates: { Ad_Hoc: {} } } },
'/api/schemes': { '1': { name: 'testScheme' } }, '/api/schemes': { '1': { name: 'testScheme' } },
'/api/branches': ['main', 'dev'], '/api/branches': ['main', 'dev'],
'/api/tasks': [ '/api/tasks': [
@@ -122,7 +145,7 @@ describe('BuildView.vue', () => {
}) })
}) })
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() await flushPromises()
const taskItems = wrapper.findAll('.task-item') const taskItems = wrapper.findAll('.task-item')
@@ -130,7 +153,7 @@ describe('BuildView.vue', () => {
}) })
it('状态文本正确', async () => { it('状态文本正确', async () => {
const wrapper = mount(BuildView) const wrapper = mountBuildView()
await flushPromises() await flushPromises()
expect(wrapper.vm.statusText('pending')).toBe('等待中') expect(wrapper.vm.statusText('pending')).toBe('等待中')
+105 -18
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import ConfigView from '../views/ConfigView.vue' import ConfigView from '../views/ConfigView.vue'
global.fetch = vi.fn() global.fetch = vi.fn()
@@ -23,6 +24,15 @@ function mockFetch(url) {
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'], '/api/config/branches': ['main', 'dev'],
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' }, '/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',
tracks: {
release: { app_ver: '2.195.0', build_ver: '2.195.0.0', build_map: {} },
feature: { app_ver: '2.100.0', build_ver: '2.100.0.0', build_map: {} },
},
branch_track: { main: 'release', dev: 'release' },
},
'/api/config': { apps: {}, schemes: {}, branches: ['main'] }, '/api/config': { apps: {}, schemes: {}, branches: ['main'] },
} }
return Promise.resolve({ return Promise.resolve({
@@ -30,6 +40,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', () => { describe('ConfigView.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -39,21 +60,19 @@ describe('ConfigView.vue', () => {
}) })
it('未登录时显示权限提示', async () => { it('未登录时显示权限提示', async () => {
const wrapper = mount(ConfigView) const wrapper = mountConfigView({ loggedIn: false })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('需要管理员权限') expect(wrapper.text()).toContain('请先登录')
}) })
it('已登录时显示管理页面', async () => { it('已登录时显示管理页面', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('配置管理') expect(wrapper.text()).toContain('配置管理')
}) })
it('显示侧边栏菜单', async () => { it('显示侧边栏菜单', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const menuItems = wrapper.findAll('.sidebar-menu li') const menuItems = wrapper.findAll('.sidebar-menu li')
@@ -65,16 +84,14 @@ describe('ConfigView.vue', () => {
expect(texts).toContain('打包设置') expect(texts).toContain('打包设置')
}) })
it('默认显示服务器环境 tab', async () => { it('默认显示 Apps 配置 tab', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('服务器环境配置') expect(wrapper.text()).toContain('Apps 配置')
}) })
it('切换到分支管理 tab', async () => { it('切换到分支管理 tab', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@@ -93,8 +110,7 @@ describe('ConfigView.vue', () => {
return mockFetch(url) return mockFetch(url)
}) })
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@@ -122,8 +138,7 @@ describe('ConfigView.vue', () => {
return mockFetch(url) return mockFetch(url)
}) })
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@@ -147,8 +162,7 @@ describe('ConfigView.vue', () => {
}) })
window.alert = vi.fn() window.alert = vi.fn()
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置') const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
@@ -161,4 +175,77 @@ describe('ConfigView.vue', () => {
expect(window.alert).toHaveBeenCalledWith('设置已保存') expect(window.alert).toHaveBeenCalledWith('设置已保存')
}) })
it('普通账号只能管理 Apps,看不到打包设置与版本号', async () => {
const wrapper = mountConfigView({ admin: false })
await flushPromises()
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
expect(menuTexts).toEqual(['Apps 配置'])
expect(wrapper.text()).not.toContain('上架版本号')
expect(wrapper.text()).not.toContain('自测版本号')
expect(wrapper.text()).not.toContain('打包参数')
// 版本号接口为管理员专属,普通账号不再请求
expect(fetch).not.toHaveBeenCalledWith('/api/config/versions', expect.anything())
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/config/apps'])
})
it('两条版本轨道各自独立展示与保存', async () => {
const calls = []
fetch.mockImplementation((url, opts) => {
if (url === '/api/config/versions' && opts?.method === 'PUT') {
calls.push(JSON.parse(opts.body))
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ app_ver: '2.101.0', build_ver: '2.101.0.0' }),
})
}
return mockFetch(url)
})
window.alert = vi.fn()
const wrapper = mountConfigView()
await flushPromises()
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
await buildMenu.trigger('click')
await wrapper.vm.$nextTick()
const inputs = wrapper.findAll('input[type="text"]')
expect(inputs[0].element.value).toBe('2.195.0') // release App_Ver
expect(inputs[2].element.value).toBe('2.100.0') // feature App_Ver
await inputs[2].setValue('2.101.0')
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存自测版本号')
await saveBtn.trigger('click')
await flushPromises()
expect(calls).toEqual([{ track: 'feature', app_ver: '2.101.0', build_ver: '2.101.0.0' }])
})
it('分支管理可切换版本轨道', async () => {
const calls = []
fetch.mockImplementation((url, opts) => {
if (url === '/api/config/branches/track' && opts?.method === 'PUT') {
calls.push(JSON.parse(opts.body))
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
}
return mockFetch(url)
})
const wrapper = mountConfigView()
await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
await branchesMenu.trigger('click')
await wrapper.vm.$nextTick()
const select = wrapper.find('.config-table select')
expect(select.element.value).toBe('release')
await select.setValue('feature')
await flushPromises()
expect(calls).toEqual([{ branch: 'main', track: 'feature' }])
})
}) })
+70 -15
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router' import { createRouter, createMemoryHistory } from 'vue-router'
import HistoryView from '../views/HistoryView.vue' import HistoryView from '../views/HistoryView.vue'
@@ -7,9 +8,9 @@ global.fetch = vi.fn()
window.open = vi.fn() window.open = vi.fn()
const mockTasks = [ 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: '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', error_message: '构建失败', error_category: 'compilation' }, { 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' }, { 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() { 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', () => { describe('HistoryView.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -78,7 +88,7 @@ describe('HistoryView.vue', () => {
expect(wrapper.text()).toContain('等待中') expect(wrapper.text()).toContain('等待中')
}) })
it('按打包类型过滤', async () => { it('版本号筛选为下拉选项', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
@@ -86,7 +96,9 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') 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() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
@@ -101,27 +113,62 @@ describe('HistoryView.vue', () => {
await flushPromises() await flushPromises()
const selects = wrapper.findAll('.filter-select') const selects = wrapper.findAll('.filter-select')
await selects[1].setValue('failed') await selects[2].setValue('failed')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr') const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBe(1) // App2 expect(rows.length).toBe(1) // App2
}) })
it('已完成任务显示下载按钮', async () => { it('按 App 名称和版本号下拉选项筛选', async () => {
const router = createMockRouter() const router = createMockRouter()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
}) })
await flushPromises() 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 和下载按钮 // 第一行(App1, completed)应该有 dSYM 和下载按钮
const firstRow = wrapper.findAll('tbody tr')[0] const firstRow = wrapper.findAll('tbody tr')[0]
const buttons = firstRow.findAll('.action-btn') const buttons = firstRow.findAll('.action-btn')
const buttonTexts = buttons.map(b => b.text()) const buttonTexts = buttons.map(b => b.text())
expect(buttonTexts).toContain('dSYM') expect(buttonTexts).toContain('dSYM')
expect(buttonTexts).toContain('下载') expect(firstRow.find('.qr-thumb').exists()).toBe(true)
expect(buttonTexts).toContain('二维码') })
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 () => {
const wrapper = mountHistory(true)
await flushPromises()
const toggle = wrapper.find('.superseded-toggle input')
expect(toggle.exists()).toBe(true)
await toggle.setValue(true)
await flushPromises()
expect(fetch).toHaveBeenLastCalledWith('/api/tasks?limit=100&include_superseded=true', expect.any(Object))
}) })
it('空列表显示提示', async () => { it('空列表显示提示', async () => {
@@ -148,18 +195,27 @@ describe('HistoryView.vue', () => {
expect(wrapper.vm.statusText('failed')).toBe('失败') expect(wrapper.vm.statusText('failed')).toBe('失败')
}) })
it('点击查看日志跳转', async () => { it('显示 App 版本号,Scheme 与状态单元格允许完整换行', async () => {
const router = createMockRouter() const router = createMockRouter()
router.push = vi.fn()
const wrapper = mount(HistoryView, { const wrapper = mount(HistoryView, {
global: { plugins: [router] }, global: { plugins: [router] },
}) })
await flushPromises() 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() === '日志') const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
await logBtn.trigger('click') await logBtn.trigger('click')
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } }) expect(wrapper.find('.log-modal').exists()).toBe(true)
}) })
it('点击二维码弹出弹窗', async () => { it('点击二维码弹出弹窗', async () => {
@@ -169,11 +225,10 @@ describe('HistoryView.vue', () => {
}) })
await flushPromises() await flushPromises()
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码') await wrapper.find('.qr-thumb').trigger('click')
await qrBtn.trigger('click')
await wrapper.vm.$nextTick() 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('下载二维码') expect(wrapper.text()).toContain('下载二维码')
}) })
}) })
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import OperationLogView from '../views/OperationLogView.vue'
global.fetch = vi.fn()
describe('OperationLogView.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
fetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ total: 4, action_counts: { build_created: 2, ipa_download_requested: 1 }, top_users: [{ name: 'admin', count: 4 }], top_apps: [{ name: '测试App', count: 3 }] }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([{ id: '1', created_at: '2026-07-22T08:00:00', username: 'admin', action: 'build_created', app_name: '测试App', resource_type: '', client_ip: '127.0.0.1' }]) })
})
it('管理员可查看统计和操作明细', async () => {
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(true), getToken: () => 'token' } } })
await flushPromises()
expect(wrapper.text()).toContain('操作日志')
expect(wrapper.text()).toContain('操作总数')
expect(wrapper.text()).toContain('发起打包')
expect(wrapper.text()).toContain('测试App')
})
it('普通用户不渲染操作日志页面', async () => {
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(false) } } })
await flushPromises()
expect(wrapper.find('.panel').exists()).toBe(false)
})
})
+2
View File
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import BuildView from '../views/BuildView.vue' import BuildView from '../views/BuildView.vue'
import HistoryView from '../views/HistoryView.vue' import HistoryView from '../views/HistoryView.vue'
import ConfigView from '../views/ConfigView.vue' import ConfigView from '../views/ConfigView.vue'
import OperationLogView from '../views/OperationLogView.vue'
const routes = [ const routes = [
{ path: '/', redirect: '/build' }, { path: '/', redirect: '/build' },
{ path: '/build', name: 'Build', component: BuildView }, { path: '/build', name: 'Build', component: BuildView },
{ path: '/history', name: 'History', component: HistoryView }, { path: '/history', name: 'History', component: HistoryView },
{ path: '/admin', name: 'Admin', component: ConfigView }, { path: '/admin', name: 'Admin', component: ConfigView },
{ path: '/operations', name: 'Operations', component: OperationLogView },
] ]
const router = createRouter({ const router = createRouter({
+22 -4
View File
@@ -53,6 +53,7 @@
<label for="obfuscation">启用混淆</label> <label for="obfuscation">启用混淆</label>
</div> </div>
</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"> <button class="btn btn-primary" @click="submitTask" :disabled="submitting">
{{ submitting ? '提交中...' : '开始打包' }} {{ submitting ? '提交中...' : '开始打包' }}
</button> </button>
@@ -126,7 +127,7 @@
</div> </div>
</div> </div>
<div v-if="completedTask.build_type === 'Ad_Hoc' && completedTask.qr_code_path" class="qr-section"> <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> <p class="qr-hint">扫码下载安装</p>
</div> </div>
</div> </div>
@@ -193,6 +194,7 @@ const form = ref({
branch: 'main', branch: 'main',
}) })
const submitting = ref(false) const submitting = ref(false)
const submitNotice = ref('')
const currentTaskId = ref(null) const currentTaskId = ref(null)
const completedTask = ref(null) const completedTask = ref(null)
const logs = ref([]) const logs = ref([])
@@ -294,7 +296,12 @@ watch(selectedServer, () => {
form.value.app_id = '' form.value.app_id = ''
}) })
watch(() => 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.scheme_id = filteredSchemes.value[0]?.[0] || ''
form.value.build_type = availableBuildTypes.value[0] || '' form.value.build_type = availableBuildTypes.value[0] || ''
}) })
@@ -347,6 +354,7 @@ onMounted(async () => {
}) })
const submitTask = async () => { const submitTask = async () => {
if (submitting.value) return
if (!form.value.app_id) { if (!form.value.app_id) {
alert('请选择 App') alert('请选择 App')
return return
@@ -362,11 +370,20 @@ const submitTask = async () => {
const task = await res.json() const task = await res.json()
currentTaskId.value = task.id currentTaskId.value = task.id
tasks.value.unshift(task) tasks.value.unshift(task)
submitNotice.value = '打包任务已创建,正在排队,请勿重复点击。'
selectedServer.value = ''
form.value = {
app_id: '',
build_type: '',
scheme_id: '',
obfuscation: false,
branch: branches.value[0] || 'main',
}
} else { } else {
alert('提交失败') submitNotice.value = '提交失败,请检查配置后重试。'
} }
} catch (e) { } catch (e) {
alert('提交失败') submitNotice.value = '提交失败,请检查网络后重试。'
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -443,6 +460,7 @@ const errorCategoryHint = (cat) => {
.btn-primary { background: #1890ff; color: white; } .btn-primary { background: #1890ff; color: white; }
.btn-primary:hover { background: #40a9ff; } .btn-primary:hover { background: #40a9ff; }
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; } .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; } .btn-danger { background: #ff4d4f; color: white; }
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; } .right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
+220 -43
View File
@@ -66,6 +66,7 @@
<tr> <tr>
<th>环境名称</th> <th>环境名称</th>
<th>API 地址</th> <th>API 地址</th>
<th>App ID 前缀</th>
<th>Associated Domains</th> <th>Associated Domains</th>
<th>Universal Link</th> <th>Universal Link</th>
<th>操作</th> <th>操作</th>
@@ -75,6 +76,7 @@
<tr v-for="(server, name) in servers" :key="name"> <tr v-for="(server, name) in servers" :key="name">
<td><strong>{{ name }}</strong></td> <td><strong>{{ name }}</strong></td>
<td>{{ server.api }}</td> <td>{{ server.api }}</td>
<td>{{ server.app_id_prefix }}</td>
<td>{{ server.assDom }}</td> <td>{{ server.assDom }}</td>
<td>{{ server.universalLink }}</td> <td>{{ server.universalLink }}</td>
<td class="action-btns"> <td class="action-btns">
@@ -149,7 +151,10 @@
<!-- 分支管理 --> <!-- 分支管理 -->
<div v-if="tab === 'branches'"> <div v-if="tab === 'branches'">
<h2>分支管理</h2> <h2>分支管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理可打包的代码分支打包时从对应分支目录获取源码</p> <p style="color: #666; margin-bottom: 16px; font-size: 14px;">
管理可打包的代码分支打包时从对应分支目录获取源码
版本轨道决定该分支使用哪一套版本号上架master/develop 共用App_Store 打包构建号自增自测feature 构建号不自增
</p>
<div style="display: flex; gap: 12px; margin-bottom: 16px;"> <div style="display: flex; gap: 12px; margin-bottom: 16px;">
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;"> <input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button> <button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
@@ -158,18 +163,25 @@
<thead> <thead>
<tr> <tr>
<th>分支名称</th> <th>分支名称</th>
<th>版本轨道</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="b in branches" :key="b"> <tr v-for="b in branches" :key="b">
<td><strong>{{ b }}</strong></td> <td><strong>{{ b }}</strong></td>
<td>
<select :value="branchTrack[b] || 'feature'" style="width: auto; padding: 4px 8px;" @change="setBranchTrack(b, $event.target.value)">
<option value="release">上架master/develop</option>
<option value="feature">自测feature</option>
</select>
</td>
<td class="action-btns"> <td class="action-btns">
<button class="action-btn delete" @click="deleteBranch(b)">删除</button> <button class="action-btn delete" @click="deleteBranch(b)">删除</button>
</td> </td>
</tr> </tr>
<tr v-if="!branches.length"> <tr v-if="!branches.length">
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td> <td colspan="3" style="text-align: center; color: #999;">暂无分支配置</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -267,34 +279,54 @@
<div v-if="tab === 'build'"> <div v-if="tab === 'build'">
<h2>打包设置</h2> <h2>打包设置</h2>
<div style="max-width: 500px;"> <div style="max-width: 500px;">
<h4 class="section-title">应用版本号</h4> <h4 class="section-title">上架版本号master / develop</h4>
<div class="form-group"> <div class="form-group">
<label>App_Ver应用版本</label> <label>App_Ver应用版本</label>
<input type="text" v-model="versions.app_ver" placeholder="2.180.0"> <input type="text" v-model="versions.release.app_ver" placeholder="2.180.0" @input="onAppVerInput('release')">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号master develop 共用这一套版本号</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Build_Ver构建版本</label> <label>Build_Ver构建</label>
<input type="text" v-model="versions.build_ver" placeholder="2.180.0.0"> <input type="text" v-model="versions.release.build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">格式主版本.次版本.修订号.构建号App_Store 打包时构建号自动 +1</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">
四段数字前三位须与 App_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号
每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改
</div>
</div> </div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button> <button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('release')">保存上架版本号</button>
<h4 class="section-title">打包参数</h4> <h4 class="section-title">自测版本号feature 分支</h4>
<div class="form-group"> <div class="form-group">
<label>最大并行打包数</label> <label>App_Ver应用版本</label>
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4"> <input type="text" v-model="versions.feature.app_ver" placeholder="2.180.0" @input="onAppVerInput('feature')">
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4过高可能影响构建稳定性</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">与上架版本号各自独立互不影响</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>打包目录保留时间小时</label> <label>Build_Ver构建号</label>
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1"> <input type="text" v-model="versions.feature.build_ver" placeholder="2.180.0.0">
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅供自测打包时<strong>不会自动递增</strong>始终使用这里填写的值默认 .0需要区分时请手动修改
</div>
</div> </div>
<div class="form-group"> <button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('feature')">保存自测版本号</button>
<label>打包基础目录</label>
<input type="text" v-model="buildSettings.build_base_dir"> <template v-if="isAdmin">
</div> <h4 class="section-title">打包参数</h4>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button> <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>
</div> </div>
@@ -325,7 +357,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>密码 *</label> <label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 12 位"> <input v-model="userForm.password" type="password" placeholder="至少 11 位">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
@@ -349,7 +381,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>新密码</label> <label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 12 位"> <input v-model="newPassword" type="password" placeholder="至少 11 位">
</div> </div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;"> <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> <button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
@@ -381,6 +413,11 @@
<label>Universal Link</label> <label>Universal Link</label>
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn"> <input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
</div> </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;"> <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="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button> <button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
@@ -413,6 +450,10 @@
</select> </select>
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境"> <input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
</div> </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>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@@ -450,7 +491,7 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>腾讯 AppID</label> <label>腾讯 AppID</label>
<input v-model="appForm.tencent" type="text" placeholder="tencent..."> <input v-model="appForm.tencent" type="text" placeholder="tencent...(可只填数字,自动补前缀)" @blur="normalizeTencentAppId">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>阿里云 License Key</label> <label>阿里云 License Key</label>
@@ -511,12 +552,21 @@
<div v-for="skin in appSkins" :key="skin.name" class="skin-item"> <div v-for="skin in appSkins" :key="skin.name" class="skin-item">
<span class="skin-name">{{ skin.name }}</span> <span class="skin-name">{{ skin.name }}</span>
<span class="skin-size">{{ formatSize(skin.size) }}</span> <span class="skin-size">{{ formatSize(skin.size) }}</span>
<label class="skin-update-btn" :class="{ disabled: skinUploading }" title="更新(保持文件名不变)">
更新
<input type="file" accept=".zip" @change="updateSkin(skin.name, $event)" :disabled="skinUploading" hidden>
</label>
<button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button> <button class="btn-icon btn-danger-icon" @click="deleteSkin(skin.name)" title="删除">&times;</button>
</div> </div>
</div> </div>
<div v-else class="skins-hint">暂无皮肤包</div> <div v-else class="skins-hint">暂无皮肤包</div>
<label class="btn btn-outline skin-upload-btn" :class="{ disabled: skinUploading }"> <label class="btn btn-outline skin-upload-btn"
{{ skinUploading ? '上传中...' : '上传皮肤包 (.zip)' }} :class="{ disabled: skinUploading, dragover: skinDragover }"
@dragover.prevent="skinDragover = true"
@dragenter.prevent="skinDragover = true"
@dragleave.prevent="skinDragover = false"
@drop.prevent="onSkinDrop">
{{ skinUploading ? '上传中...' : (skinDragover ? '松开鼠标上传' : '上传皮肤包 (.zip或拖拽到此处)') }}
<input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden> <input type="file" accept=".zip" @change="uploadSkin" :disabled="skinUploading" hidden>
</label> </label>
</div> </div>
@@ -558,7 +608,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, inject } from 'vue' import { computed, ref, onMounted, inject } from 'vue'
const showLogin = inject('showLogin') const showLogin = inject('showLogin')
const getToken = inject('getToken') const getToken = inject('getToken')
@@ -588,19 +638,51 @@ const servers = ref({})
const branches = ref([]) const branches = ref([])
const newBranch = ref('') const newBranch = ref('')
const buildSettings = ref({}) const buildSettings = ref({})
const versions = ref({ app_ver: '', build_ver: '' }) // 两条版本轨道:releasemaster/develop,上架用)与 feature(自测用),各自独立
const versions = ref({
release: { app_ver: '', build_ver: '' },
feature: { app_ver: '', build_ver: '' },
})
const buildMap = ref({ release: {}, feature: {} })
const branchTrack = ref({})
const applyVersions = (data) => {
const tracks = data.tracks || {}
for (const track of ['release', 'feature']) {
const item = tracks[track] || {}
buildMap.value[track] = item.build_map || {}
versions.value[track] = { app_ver: item.app_ver || '', build_ver: item.build_ver || '' }
}
branchTrack.value = data.branch_track || {}
}
// 修改 App_Ver 时,自动把 Build_Ver 切换为该版本号已记录的构建号(新版本号则为 .0)
const onAppVerInput = (track) => {
const appVer = (versions.value[track].app_ver || '').trim()
if (/^\d+\.\d+\.\d+$/.test(appVer)) {
const n = Number((buildMap.value[track] || {})[appVer] || 0)
versions.value[track].build_ver = `${appVer}.${n}`
}
}
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} }) const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
const jsonContent = ref('{}') const jsonContent = ref('{}')
const showServerModal = ref(false) const showServerModal = ref(false)
const editingServerName = ref(null) 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 showAppModal = ref(false)
const editingAppId = ref(null) const editingAppId = ref(null)
const appForm = ref({}) const appForm = ref({})
const appSkins = ref([]) const appSkins = ref([])
const skinUploading = ref(false) const skinUploading = ref(false)
const skinDragover = ref(false)
const showSchemeModal = ref(false) const showSchemeModal = ref(false)
const editingSchemeId = ref(null) const editingSchemeId = ref(null)
@@ -612,6 +694,7 @@ onMounted(async () => {
const loadData = async () => { const loadData = async () => {
if (!isAdmin.value) { if (!isAdmin.value) {
// 普通用户只能管理 Apps,版本号等配置均为管理员专属
const appsRes = await authFetch('/api/config/apps') const appsRes = await authFetch('/api/config/apps')
if (appsRes.ok) apps.value = await appsRes.json() if (appsRes.ok) apps.value = await appsRes.json()
return return
@@ -635,7 +718,7 @@ const loadData = async () => {
buildSettings.value = await buildRes.json() buildSettings.value = await buildRes.json()
uploadConfig.value = await uploadRes.json() uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2) jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
versions.value = await versionsRes.json() applyVersions(await versionsRes.json())
if (usersRes.ok) users.value = await usersRes.json() if (usersRes.ok) users.value = await usersRes.json()
} }
@@ -650,8 +733,8 @@ const createUser = async () => {
alert('请填写用户名和密码') alert('请填写用户名和密码')
return return
} }
if (userForm.value.password.length < 12) { if (userForm.value.password.length < 11) {
alert('密码至少 12 位') alert('密码至少 11 位')
return return
} }
try { try {
@@ -678,8 +761,8 @@ const openPasswordModal = (u) => {
} }
const changePassword = async () => { const changePassword = async () => {
if (!newPassword.value || newPassword.value.length < 12) { if (!newPassword.value || newPassword.value.length < 11) {
alert('密码至少 12 位') alert('密码至少 11 位')
return return
} }
try { try {
@@ -727,7 +810,9 @@ const deleteUser = async (u) => {
// 服务器环境管理 // 服务器环境管理
const openServerModal = (name = null, server = null) => { const openServerModal = (name = null, server = null) => {
editingServerName.value = name 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 showServerModal.value = true
} }
@@ -800,6 +885,7 @@ const openAppModal = (id = null, app = null) => {
weixinpay: '', weixinpay: '',
tencent: '', tencent: '',
AlivcLicenseKey: '', AlivcLicenseKey: '',
app_id_prefix_override: '',
certificates: {}, certificates: {},
} }
} }
@@ -841,9 +927,9 @@ const loadSkins = async () => {
} catch { appSkins.value = [] } } catch { appSkins.value = [] }
} }
const uploadSkin = async (event) => { const uploadSkinFile = async (file) => {
const file = event.target.files[0]
if (!file) return if (!file) return
if (!file.name.toLowerCase().endsWith('.zip')) { alert('仅支持 .zip 格式'); return }
const uploadKey = appForm.value.upload_key const uploadKey = appForm.value.upload_key
if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return } if (!uploadKey) { alert('请先保存 APP 后再上传皮肤包'); return }
skinUploading.value = true skinUploading.value = true
@@ -859,7 +945,12 @@ const uploadSkin = async (event) => {
throw new Error(err.detail || '上传失败') throw new Error(err.detail || '上传失败')
} }
await loadSkins() await loadSkins()
event.target.value = '' // 若某证书的主题目录还是空的,自动选中刚上传的皮肤
const skinTheme = file.name.replace(/\.zip$/i, '')
const certs = appForm.value.certificates || {}
Object.keys(certs).forEach(certType => {
if (!certs[certType].theme) certs[certType].theme = skinTheme
})
} catch (e) { } catch (e) {
alert('上传失败: ' + e.message) alert('上传失败: ' + e.message)
} finally { } finally {
@@ -867,6 +958,44 @@ const uploadSkin = async (event) => {
} }
} }
const uploadSkin = async (event) => {
await uploadSkinFile(event.target.files[0])
event.target.value = ''
}
const onSkinDrop = (event) => {
skinDragover.value = false
if (skinUploading.value) return
const file = event.dataTransfer.files[0]
uploadSkinFile(file)
}
const updateSkin = async (skinName, event) => {
const file = event.target.files[0]
if (!file) return
const uploadKey = appForm.value.upload_key
if (!uploadKey) { event.target.value = ''; return }
skinUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const res = await authFetch(`/api/config/skins/${uploadKey}/${skinName}`, {
method: 'PUT',
body: formData,
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '更新失败')
}
await loadSkins()
} catch (e) {
alert('更新失败: ' + e.message)
} finally {
event.target.value = ''
skinUploading.value = false
}
}
const deleteSkin = async (skinName) => { const deleteSkin = async (skinName) => {
if (!confirm(`确定删除皮肤包 ${skinName}`)) return if (!confirm(`确定删除皮肤包 ${skinName}`)) return
const uploadKey = appForm.value.upload_key const uploadKey = appForm.value.upload_key
@@ -885,12 +1014,20 @@ const formatSize = (bytes) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB' return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
} }
// 腾讯 AppID 统一带 tencent 前缀(允许只填数字 ID)
const normalizeTencentAppId = () => {
const value = (appForm.value.tencent || '').trim()
appForm.value.tencent = value && !value.startsWith('tencent') ? `tencent${value}` : value
}
const saveApp = async () => { const saveApp = async () => {
if (!appForm.value.name || !appForm.value.AppGuid) { if (!appForm.value.name || !appForm.value.AppGuid) {
alert('请填写必填字段:App 名称、AppGuid') alert('请填写必填字段:App 名称、AppGuid')
return return
} }
normalizeTencentAppId()
// 清理空证书 // 清理空证书
if (appForm.value.certificates) { if (appForm.value.certificates) {
Object.keys(appForm.value.certificates).forEach(key => { Object.keys(appForm.value.certificates).forEach(key => {
@@ -927,9 +1064,18 @@ const saveApp = async () => {
const err = await res.json().catch(() => ({ detail: '保存失败' })) const err = await res.json().catch(() => ({ detail: '保存失败' }))
throw new Error(err.detail || '保存失败') throw new Error(err.detail || '保存失败')
} }
showAppModal.value = false
await loadData() await loadData()
alert('保存成功') if (!editingAppId.value) {
// 新建成功:不关弹窗,转为编辑态并回填 upload_key,让皮肤包上传区立即可用
const data = await res.json().catch(() => ({}))
editingAppId.value = data.id || editingAppId.value
if (data.upload_key) appForm.value.upload_key = data.upload_key
await loadSkins()
alert('保存成功,现在可以上传皮肤包了')
} else {
showAppModal.value = false
alert('保存成功')
}
} catch (e) { } catch (e) {
alert('保存失败: ' + e.message) alert('保存失败: ' + e.message)
} }
@@ -1005,6 +1151,24 @@ const addBranch = async () => {
} }
} }
const setBranchTrack = async (name, track) => {
try {
const res = await authFetch('/api/config/branches/track', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: name, track }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '修改失败')
}
branchTrack.value = { ...branchTrack.value, [name]: track }
} catch (e) {
alert('修改失败: ' + e.message)
await loadData()
}
}
const deleteBranch = async (name) => { const deleteBranch = async (name) => {
if (!confirm(`确定删除分支「${name}」?`)) return if (!confirm(`确定删除分支「${name}」?`)) return
try { try {
@@ -1026,16 +1190,25 @@ const saveBuildSettings = async () => {
alert('设置已保存') alert('设置已保存')
} }
const saveVersions = async () => { const saveVersions = async (track = 'release') => {
try { try {
const res = await authFetch('/api/config/versions', { const res = await authFetch('/api/config/versions', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(versions.value), body: JSON.stringify({
track,
app_ver: versions.value[track].app_ver,
build_ver: versions.value[track].build_ver,
}),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
versions.value = { app_ver: data.app_ver, build_ver: data.build_ver } // 回写该版本号最新的构建号,保持本地 build_map 与服务端一致
buildMap.value[track] = {
...(buildMap.value[track] || {}),
[data.app_ver]: Number((data.build_ver.split('.')[3]) || 0),
}
versions.value[track] = { app_ver: data.app_ver, build_ver: data.build_ver }
alert('版本号已保存') alert('版本号已保存')
} else { } else {
const err = await res.json() const err = await res.json()
@@ -1139,9 +1312,13 @@ const formatJson = () => {
.btn-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; } .btn-icon { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.btn-danger-icon { background: #fff1f0; color: #ff4d4f; } .btn-danger-icon { background: #fff1f0; color: #ff4d4f; }
.btn-danger-icon:hover { background: #ff4d4f; color: white; } .btn-danger-icon:hover { background: #ff4d4f; color: white; }
.skin-update-btn { font-size: 12px; color: #1890ff; cursor: pointer; padding: 2px 8px; border: 1px solid #91d5ff; border-radius: 4px; background: #e6f7ff; line-height: 1.4; }
.skin-update-btn:hover { background: #1890ff; color: white; }
.skin-update-btn.disabled { opacity: 0.5; pointer-events: none; }
.btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; } .btn-outline { background: white; border: 1px solid #1890ff; color: #1890ff; cursor: pointer; padding: 6px 16px; border-radius: 6px; font-size: 13px; display: inline-block; }
.btn-outline:hover { background: #e6f7ff; } .btn-outline:hover { background: #e6f7ff; }
.btn-outline.disabled { opacity: 0.5; cursor: not-allowed; } .btn-outline.disabled { opacity: 0.5; cursor: not-allowed; }
.skin-upload-btn { margin-top: 8px; } .skin-upload-btn { margin-top: 8px; transition: background 0.15s, border-color 0.15s; }
.skin-upload-btn.dragover { border-color: #1890ff; border-style: dashed; background: #e6f7ff; color: #1890ff; }
.theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; } .theme-select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
</style> </style>
+76 -39
View File
@@ -4,10 +4,13 @@
<div class="header-row"> <div class="header-row">
<h2>打包历史</h2> <h2>打包历史</h2>
<div class="filters"> <div class="filters">
<select v-model="filterBuildType" class="filter-select"> <select v-model="filterAppName" class="filter-select">
<option value="">全部类型</option> <option value="">全部 App</option>
<option value="Ad_Hoc">Ad_Hoc</option> <option v-for="appName in appNames" :key="appName" :value="appName">{{ appName }}</option>
<option value="App_Store">App_Store</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>
<select v-model="filterStatus" class="filter-select"> <select v-model="filterStatus" class="filter-select">
<option value="">全部状态</option> <option value="">全部状态</option>
@@ -17,35 +20,31 @@
<option value="failed">失败</option> <option value="failed">失败</option>
<option value="cancelled">已取消</option> <option value="cancelled">已取消</option>
</select> </select>
<label v-if="isAdmin" class="superseded-toggle"><input v-model="showSuperseded" type="checkbox" @change="loadTasks"> 显示已覆盖</label>
</div> </div>
</div> </div>
<table class="config-table"> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>App 名称</th>
<th>App</th> <th>App 版本号</th>
<th>打包类型</th> <th>打包类型</th>
<th>Scheme</th> <th>Scheme</th>
<th>状态</th> <th>状态</th>
<th>下载地址</th> <th>下载地址</th>
<th>操作</th> <th v-if="isAdmin">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="task in filteredTasks" :key="task.id"> <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_name }}</td>
<td>{{ task.app_version }}</td>
<td> <td>
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']"> <span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
{{ task.build_type }} {{ task.build_type }}
</span> </span>
</td> </td>
<td>{{ task.scheme_name }}</td> <td class="scheme-cell">{{ task.scheme_name }}</td>
<td class="status-cell"> <td class="status-cell">
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span> <span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
<span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge"> <span v-if="task.status === 'failed' && task.error_category" class="error-cat-badge">
@@ -57,11 +56,11 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path" <img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb" :src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="showQrPreview(task)"> @click="showQrPreview(task)">
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a> <button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
</template> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </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.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.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> <button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
@@ -69,7 +68,7 @@
</td> </td>
</tr> </tr>
<tr v-if="!filteredTasks.length"> <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> </tr>
</tbody> </tbody>
</table> </table>
@@ -162,7 +161,8 @@
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue' 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 tasks = ref([])
const authFetch = (url, options = {}) => { const authFetch = (url, options = {}) => {
@@ -172,16 +172,31 @@ const authFetch = (url, options = {}) => {
} }
return fetch(url, options) return fetch(url, options)
} }
const filterBuildType = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const filterAppName = ref('')
const filterVersion = ref('')
const showLogModal = ref(false) const showLogModal = ref(false)
const logTask = ref(null) const logTask = ref(null)
const logLines = ref([]) const logLines = ref([])
const logContainer = ref(null) const logContainer = ref(null)
const showVerboseLogs = ref(false) const showVerboseLogs = ref(false)
const qrPreview = ref(null) const qrPreview = ref(null)
const showSuperseded = ref(false)
let logWs = 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) => { const showQrPreview = (task) => {
qrPreview.value = task qrPreview.value = task
} }
@@ -190,16 +205,20 @@ const filteredTasks = computed(() => {
return tasks.value.filter(task => { return tasks.value.filter(task => {
// 默认隐藏已取消的任务 // 默认隐藏已取消的任务
if (!filterStatus.value && task.status === 'cancelled') return false 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 if (filterStatus.value && task.status !== filterStatus.value) return false
return true return true
}) })
}) })
onMounted(async () => { const loadTasks = async () => {
const res = await authFetch('/api/tasks?limit=100') const res = await authFetch(`/api/tasks?limit=100${showSuperseded.value ? '&include_superseded=true' : ''}`)
tasks.value = await res.json() const data = await res.json()
}) tasks.value = data.map(task => ({ ...task, app_version: getAppVersion(task) }))
}
onMounted(loadTasks)
const viewLogs = async (taskId) => { const viewLogs = async (taskId) => {
const task = tasks.value.find(t => t.id === taskId) const task = tasks.value.find(t => t.id === taskId)
@@ -293,22 +312,40 @@ const downloadDsym = (taskId) => {
window.open(`/api/tasks/${taskId}/dsym`) window.open(`/api/tasks/${taskId}/dsym`)
} }
const downloadIpa = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error()
const { url } = await res.json()
window.open(url, '_blank')
} catch {
alert('下载失败,请稍后重试')
}
}
const downloadObfMaps = (taskId) => { const downloadObfMaps = (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`) window.open(`/api/tasks/${taskId}/obfuscation-maps`)
} }
const deleteTask = async (taskId) => { const deleteTask = async (taskId) => {
if (!confirm('确定要删除这条打包记录吗?')) return if (!confirm('确定要删除这条打包记录及其远端分发文件吗?')) return
try { try {
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' }) const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId) if (res.ok) {
} catch {} tasks.value = tasks.value.filter(t => t.id !== taskId)
} else {
const data = await res.json().catch(() => ({}))
alert(data.detail || '删除失败,请稍后重试')
}
} catch {
alert('删除失败,请检查网络后重试')
}
} }
const formatTime = (t) => { const formatTime = (t) => {
if (!t) return '-' if (!t) return '-'
const d = t.endsWith('Z') || t.includes('+') ? new Date(t) : new Date(t + 'Z') 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) => { const formatDuration = (started, completed) => {
@@ -362,12 +399,12 @@ const errorCategoryLabel = (cat) => {
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; } .config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
.config-table td { font-size: 14px; } .config-table td { font-size: 14px; }
.config-table th:nth-child(1) { width: 14%; } .config-table th:nth-child(1) { width: 14%; }
.config-table th:nth-child(2) { width: 14%; } .config-table th:nth-child(2) { width: 12%; }
.config-table th:nth-child(3) { width: 8%; } .config-table th:nth-child(3) { width: 10%; }
.config-table th:nth-child(4) { width: 10%; } .config-table th:nth-child(4) { width: 18%; }
.config-table th:nth-child(5) { width: 12%; } .config-table th:nth-child(5) { width: 15%; }
.config-table th:nth-child(6) { width: 16%; } .config-table th:nth-child(6) { width: 12%; }
.config-table th:nth-child(7) { width: 26%; } .config-table th:nth-child(7) { width: 19%; }
.config-table tr:hover { background: #fafafa; } .config-table tr:hover { background: #fafafa; }
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } .build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
@@ -377,11 +414,12 @@ const errorCategoryLabel = (cat) => {
.action-btns { white-space: normal; } .action-btns { white-space: normal; }
.action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; } .action-btns .action-btn { margin-right: 6px; margin-bottom: 4px; display: inline-block; vertical-align: middle; }
.download-cell { white-space: normal; } .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 { 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; } .action-btn:hover { border-color: #1890ff; color: #1890ff; }
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; } .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; } .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-cell .task-status, .status-cell .error-cat-badge { display: inline-block; margin-bottom: 4px; }
.status-pending { background: #f0f0f0; color: #666; } .status-pending { background: #f0f0f0; color: #666; }
.status-running { background: #e6f7ff; color: #1890ff; } .status-running { background: #e6f7ff; color: #1890ff; }
@@ -433,9 +471,8 @@ const errorCategoryLabel = (cat) => {
.meta-label { font-size: 12px; color: #999; } .meta-label { font-size: 12px; color: #999; }
.meta-value { font-size: 13px; color: #333; font-weight: 500; } .meta-value { font-size: 13px; color: #333; font-weight: 500; }
.duration-text { .version-filter { width: 150px; }
display: block; font-size: 11px; color: #8b949e; margin-top: 2px; .superseded-toggle { display: flex; align-items: center; gap: 4px; color: #666; font-size: 13px; white-space: nowrap; }
}
/* 日志操作栏 */ /* 日志操作栏 */
.log-modal-actions { .log-modal-actions {
@@ -485,7 +522,7 @@ const errorCategoryLabel = (cat) => {
transition: all 0.2s; transition: all 0.2s;
} }
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; } .btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; } .download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
.download-link:hover { text-decoration: underline; } .download-link:hover { text-decoration: underline; }
.text-muted { color: #999; font-size: 13px; } .text-muted { color: #999; font-size: 13px; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; } .qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
+24
View File
@@ -0,0 +1,24 @@
<template>
<div v-if="isAdmin" class="container">
<div class="panel">
<div class="header-row"><h2>操作日志</h2><div><select v-model.number="days" @change="load"><option :value="7"> 7 </option><option :value="30"> 30 </option><option :value="90"> 90 </option></select><button @click="exportCsv">导出 CSV</button></div></div>
<div class="cards"><div><strong>{{ summary.total || 0 }}</strong><span>操作总数</span></div><div><strong>{{ summary.action_counts?.build_created || 0 }}</strong><span>发起打包</span></div><div><strong>{{ summary.action_counts?.ipa_download_requested || 0 }}</strong><span>发起 IPA 下载</span></div></div>
<div class="columns"><section><h3>活跃用户</h3><p v-for="item in summary.top_users || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section><section><h3>App 操作量</h3><p v-for="item in summary.top_apps || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section></div>
<table><thead><tr><th>时间</th><th>用户</th><th>操作</th><th>App</th><th>资源</th><th>IP</th></tr></thead><tbody><tr v-for="log in logs" :key="log.id"><td>{{ formatTime(log.created_at) }}</td><td>{{ log.username }}</td><td>{{ actionText(log.action) }}</td><td>{{ log.app_name || '-' }}</td><td>{{ log.resource_type || '-' }}</td><td>{{ log.client_ip || '-' }}</td></tr><tr v-if="!logs.length"><td colspan="6">暂无操作记录</td></tr></tbody></table>
</div>
</div>
</template>
<script setup>
import { inject, ref, onMounted } from 'vue'
const isAdmin = inject('isAdmin', ref(false)); const getToken = inject('getToken', () => '')
const days = ref(30); const summary = ref({}); const logs = ref([])
const authFetch = (url) => fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })
const load = async () => { const [summaryRes, logsRes] = await Promise.all([authFetch(`/api/operations/summary?days=${days.value}`), authFetch(`/api/operations?days=${days.value}`)]); if (summaryRes.ok) summary.value = await summaryRes.json(); if (logsRes.ok) logs.value = await logsRes.json() }
const exportCsv = async () => { const res = await authFetch(`/api/operations/export?days=${days.value}`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'operation-logs.csv'; link.click(); URL.revokeObjectURL(url) }
const formatTime = value => value ? new Date(value.endsWith('Z') ? value : `${value}Z`).toLocaleString('zh-CN', { hour12: false }) : '-'
const actionText = action => ({ build_created: '发起打包', build_cancelled: '取消打包', task_deleted: '删除记录', ipa_download_requested: '发起 IPA 下载', dsym_downloaded: '下载 dSYM', obfuscation_maps_downloaded: '下载混淆映射' }[action] || action)
onMounted(load)
</script>
<style scoped>
.container{max-width:1200px;margin:0 auto;padding:24px}.panel{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px #00000014}.header-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.header-row h2{margin:0}.header-row select,.header-row button{padding:8px 12px;margin-left:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff}.header-row button{color:#1890ff;cursor:pointer}.cards,.columns{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.cards div,.columns section{padding:16px;border-radius:8px;background:#fafafa}.cards strong{display:block;font-size:24px;color:#1890ff}.cards span{font-size:13px;color:#666}.columns{grid-template-columns:1fr 1fr}.columns h3{margin:0 0 10px;font-size:14px}.columns p{display:flex;justify-content:space-between;margin:8px 0;color:#555}table{width:100%;border-collapse:collapse}th,td{padding:11px;border-bottom:1px solid #eee;text-align:left;font-size:13px}th{color:#666;background:#fafafa}@media(max-width:700px){.cards{grid-template-columns:1fr}.columns{grid-template-columns:1fr}.header-row{align-items:flex-start;gap:12px;flex-direction:column}}
</style>
+2 -1
View File
@@ -54,7 +54,8 @@ def tmp_config(tmp_path, monkeypatch):
"1": {"name": "readoor31", "ossFloder": "test"} "1": {"name": "readoor31", "ossFloder": "test"}
}, },
"servers": { "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"], "branches": ["main", "dev"],
})) }))
+148 -5
View File
@@ -20,14 +20,57 @@ def test_get_apps(client, tmp_config):
def test_create_app(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.status_code == 200
assert resp.json()["id"] == "2" assert resp.json()["id"] == "100"
# 验证已创建 # 验证已创建
apps = client.get("/api/config/apps").json() apps = client.get("/api/config/apps").json()
assert "2" in apps assert "100" in apps
assert apps["2"]["name"] == "新App" 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_create_app_prefixes_tencent_app_id(client, tmp_config):
resp = client.post("/api/config/apps", json={
"name": "腾讯App", "server": "测试环境", "AppGuid": "guid", "tencent": "123456",
})
assert resp.status_code == 200
app_id = resp.json()["id"]
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
def test_create_app_keeps_existing_tencent_prefix(client, tmp_config):
resp = client.post("/api/config/apps", json={
"name": "腾讯App2", "server": "测试环境", "AppGuid": "guid", "tencent": "tencent123456",
})
app_id = resp.json()["id"]
assert client.get("/api/config/apps").json()[app_id]["tencent"] == "tencent123456"
def test_update_app_prefixes_tencent_app_id(client, tmp_config):
resp = client.put("/api/config/apps/1", json={
"name": "改名App", "AppGuid": "test-guid", "tencent": " 987654 ",
})
assert resp.status_code == 200
assert client.get("/api/config/apps").json()["1"]["tencent"] == "tencent987654"
def test_update_app(client, tmp_config): def test_update_app(client, tmp_config):
@@ -121,7 +164,13 @@ def test_create_server(client, tmp_config):
"universalLink": "https://new.com", "universalLink": "https://new.com",
}) })
assert resp.status_code == 200 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): def test_delete_server_in_use(client, tmp_config):
@@ -144,3 +193,97 @@ def test_update_build_settings(client, tmp_config):
resp = client.put("/api/config/build", json={"max_concurrent_builds": 4}) resp = client.put("/api/config/build", json={"max_concurrent_builds": 4})
assert resp.status_code == 200 assert resp.status_code == 200
assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4 assert client.get("/api/config/build").json()["max_concurrent_builds"] == 4
# ---- Versions ----
def test_update_versions_keeps_per_version_build_number(client, tmp_config):
# 首次设置某版本号,构建号从 .0 起(尚未打包)
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_does_not_reset_used_build_number(client, tmp_config):
# 模拟 2.195.0 已打到 .5、并切到 2.196.0 后,再回退到 2.195.0
import json
with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f)
cfg["versions"] = {"app_ver": "2.196.0", "build_map": {"2.195.0": 5, "2.196.0": 3}}
with open(tmp_config, "w", encoding="utf-8") as f:
json.dump(cfg, f)
resp = client.put("/api/config/versions", json={"app_ver": "2.195.0"})
assert resp.status_code == 200
# 回退到旧版本号时,构建号保留其自身已用到的最大值,不被重置
assert resp.json()["build_ver"] == "2.195.0.5"
assert client.get("/api/config/versions").json()["build_ver"] == "2.195.0.5"
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"]
def test_versions_tracks_are_independent(client, tmp_config):
# 两条轨道的 App_Ver 各自独立,互不影响
assert client.put("/api/config/versions", json={"track": "release", "app_ver": "2.196.0"}).status_code == 200
assert client.put("/api/config/versions", json={"track": "feature", "app_ver": "2.100.0"}).status_code == 200
data = client.get("/api/config/versions").json()
assert data["tracks"]["release"]["app_ver"] == "2.196.0"
assert data["tracks"]["feature"]["app_ver"] == "2.100.0"
assert data["tracks"]["feature"]["build_ver"] == "2.100.0.0"
# 顶层字段镜像 release 轨,兼容旧客户端
assert data["app_ver"] == "2.196.0"
def test_update_versions_rejects_unknown_track(client, tmp_config):
resp = client.put("/api/config/versions", json={"track": "beta", "app_ver": "2.196.0"})
assert resp.status_code == 400
def test_legacy_versions_migrate_to_release_track(client, tmp_config):
import json
with open(tmp_config, "r", encoding="utf-8") as f:
cfg = json.load(f)
cfg["versions"] = {"app_ver": "2.195.0", "build_ver": "2.195.0.7"}
with open(tmp_config, "w", encoding="utf-8") as f:
json.dump(cfg, f)
data = client.get("/api/config/versions").json()
assert data["tracks"]["release"] == {
"app_ver": "2.195.0",
"build_ver": "2.195.0.7",
"build_map": {"2.195.0": 7},
}
# feature 轨以同一 App_Ver 初始化,构建号从 .0 起
assert data["tracks"]["feature"]["app_ver"] == "2.195.0"
assert data["tracks"]["feature"]["build_ver"] == "2.195.0.0"
def test_branch_track_defaults_and_override(client, tmp_config):
assert client.post("/api/config/branches", json={"name": "develop"}).status_code == 200
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
tracks = client.get("/api/config/versions").json()["branch_track"]
assert tracks["develop"] == "release"
assert tracks["feature/login"] == "feature"
# 手动改归属
resp = client.put("/api/config/branches/track", json={"branch": "feature/login", "track": "release"})
assert resp.status_code == 200
assert client.get("/api/config/versions").json()["branch_track"]["feature/login"] == "release"
assert client.put("/api/config/branches/track", json={"branch": "nope", "track": "release"}).status_code == 404
assert client.put("/api/config/branches/track", json={"branch": "develop", "track": "beta"}).status_code == 400
def test_delete_branch_with_slash_in_name(client, tmp_config):
assert client.post("/api/config/branches", json={"name": "feature/login"}).status_code == 200
resp = client.delete("/api/config/branches/feature%2Flogin")
assert resp.status_code == 200
assert "feature/login" not in client.get("/api/config/branches").json()
+47
View File
@@ -0,0 +1,47 @@
"""操作审计日志接口测试。"""
from unittest.mock import AsyncMock, patch
@patch("backend.services.build_queue.build_queue")
def test_build_creation_is_recorded_and_visible_to_admin(mock_queue, client, tmp_config):
mock_queue.submit = AsyncMock()
response = client.post("/api/tasks", json={"app_id": "1", "scheme_id": "1", "build_type": "Ad_Hoc"})
assert response.status_code == 200
summary = client.get("/api/operations/summary")
assert summary.status_code == 200
assert summary.json()["action_counts"]["build_created"] == 1
logs = client.get("/api/operations").json()
assert logs[0]["username"] == "admin"
assert logs[0]["action"] == "build_created"
def test_download_link_is_recorded(client, tmp_config):
from backend.database import SessionLocal
from backend.models import Task
db = SessionLocal()
db.add(Task(id="download-task", app_id="1", app_name="测试App", build_type="App_Store", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.ipa"))
db.commit()
db.close()
with patch("backend.services.distribution.get_published_download_url", return_value="https://signed.example.com/app.ipa?signature=1"):
response = client.get("/api/tasks/download-task/download-link")
assert response.status_code == 200
assert "signature=1" in response.json()["url"]
assert client.get("/api/operations").json()[0]["action"] == "ipa_download_requested"
def test_regular_user_cannot_view_operation_logs(client):
from backend.database import SessionLocal
from backend.models import User
from backend.security import hash_password
db = SessionLocal()
db.add(User(id="regular-user", username="reader", password_hash=hash_password("reader-password"), is_admin=False))
db.commit()
db.close()
login = client.post("/api/auth/login", json={"username": "reader", "password": "reader-password"})
client.headers.update({"Authorization": f"Bearer {login.json()['token']}"})
assert client.get("/api/operations").status_code == 403
assert client.get("/api/operations/summary").status_code == 403
+63
View File
@@ -167,3 +167,66 @@ def test_cancel_task(mock_queue, client, tmp_config):
# 验证状态已更新 # 验证状态已更新
task = client.get(f"/api/tasks/{task_id}").json() task = client.get(f"/api/tasks/{task_id}").json()
assert task["status"] == "cancelled" 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()
def test_history_hides_superseded_tasks_except_for_admin(client, tmp_config):
from datetime import datetime
from backend.database import SessionLocal
from backend.models import Task
db = SessionLocal()
db.add_all([
Task(id="visible", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed"),
Task(id="covered", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", superseded_at=datetime.utcnow()),
])
db.commit()
db.close()
assert [task["id"] for task in client.get("/api/tasks").json()] == ["visible"]
assert {task["id"] for task in client.get("/api/tasks?include_superseded=true").json()} == {"visible", "covered"}
+124 -7
View File
@@ -20,6 +20,7 @@ from backend.services.build_service import (
run_pod_install, run_pod_install,
_patch_afnetworking_private_headers, _patch_afnetworking_private_headers,
_cleanup_old_builds, _cleanup_old_builds,
_mark_superseded_tasks,
_resolve_provisioning_profile, _resolve_provisioning_profile,
) )
from backend.services.log_streamer import LogStreamer from backend.services.log_streamer import LogStreamer
@@ -41,7 +42,9 @@ def tmp_dirs(tmp_path):
(source_dir / "Pods").mkdir() (source_dir / "Pods").mkdir()
(source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc") (source_dir / "Podfile.lock").write_text("PODFILE CHECKSUM: abc")
(source_dir / "readoor.xcworkspace").mkdir() (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 = source_dir / "Vendor" / "RDEpubReaderView"
vendor_dir.mkdir(parents=True) vendor_dir.mkdir(parents=True)
(vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end") (vendor_dir / "RDEpubReaderView.podspec").write_text("Pod::Spec.new do |s| end")
@@ -161,7 +164,7 @@ async def test_copy_source_code(tmp_dirs, log_streamer):
assert (result / "Podfile.lock").exists() assert (result / "Podfile.lock").exists()
assert (result / "readoor.xcworkspace").exists() assert (result / "readoor.xcworkspace").exists()
assert (result / "Vendor" / "RDEpubReaderView" / "RDEpubReaderView.podspec").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): async def test_copy_source_code_normalizes_lowercase_podfile(tmp_dirs, log_streamer):
@@ -312,6 +315,14 @@ def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W") assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W")
def _versions_config(release: dict, feature: dict) -> dict:
return {
"apps": {"1": {"name": "App", "certificates": {}}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}},
"versions": {"tracks": {"release": release, "feature": feature}},
}
async def test_generate_config_uses_saved_version(tmp_path, log_streamer): async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
"""从服务配置读取版本号,不依赖分支源码脚本""" """从服务配置读取版本号,不依赖分支源码脚本"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"
@@ -322,12 +333,12 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
task.scheme_id = "1" task.scheme_id = "1"
task.build_type = "Ad_Hoc" task.build_type = "Ad_Hoc"
task.obfuscation = False task.obfuscation = False
task.branch = "master"
mock_config = { mock_config = _versions_config(
"apps": {"1": {"name": "App", "certificates": {}}}, {"app_ver": "3.0.1", "build_map": {}},
"schemes": {"1": {"name": "sch", "ossFloder": "f"}}, {"app_ver": "9.9.9", "build_map": {}},
"versions": {"app_ver": "3.0.1", "build_ver": "3.0.1.0"}, )
}
with patch("backend.routers.config.load_config", return_value=mock_config): with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir) result = await generate_config("t1", task, build_dir)
@@ -336,6 +347,96 @@ async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
assert result["BUILD_VERSION"] == "3.0.1.0" assert result["BUILD_VERSION"] == "3.0.1.0"
async def test_generate_config_appstore_increments_per_version(tmp_path, log_streamer):
"""App_Store 打包时,按版本号在其自身已用构建号上 +1(回退旧版本号亦从自身最大值继续)"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
obfuscation=False, branch="master")
# 2.195.0 已打到 .5,切到 2.196.0 后又回退到 2.195.0
mock_config = _versions_config(
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5, "2.196.0": 3}},
{"app_ver": "2.195.0", "build_map": {}},
)
with patch("backend.routers.config.load_config", return_value=mock_config), \
patch("backend.routers.config.save_config") as save_mock:
result = await generate_config("t1", task, build_dir)
# 从 2.195.0 自身的 5 继续到 6,不受 2.196.0 影响,也不撞已传的 .5
release_map = mock_config["versions"]["tracks"]["release"]["build_map"]
assert result["BUILD_VERSION"] == "2.195.0.6"
assert release_map["2.195.0"] == 6
assert release_map["2.196.0"] == 3
save_mock.assert_called()
async def test_generate_config_develop_shares_release_track(tmp_path, log_streamer):
"""develop 与 master 共用 release 轨的版本号与构建号序列"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
obfuscation=False, branch="develop")
mock_config = _versions_config(
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5}},
{"app_ver": "2.100.0", "build_map": {}},
)
with patch("backend.routers.config.load_config", return_value=mock_config), \
patch("backend.routers.config.save_config"):
result = await generate_config("t1", task, build_dir)
assert result["VERSION"] == "2.195.0"
assert result["BUILD_VERSION"] == "2.195.0.6"
async def test_generate_config_feature_branch_never_increments(tmp_path, log_streamer):
"""feature 分支用自己的版本号,且构建号永不自增(即使是 App_Store 打包)"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="App_Store",
obfuscation=False, branch="feature/login")
mock_config = _versions_config(
{"app_ver": "2.195.0", "build_map": {"2.195.0": 5}},
{"app_ver": "2.100.0", "build_map": {}},
)
with patch("backend.routers.config.load_config", return_value=mock_config), \
patch("backend.routers.config.save_config") as save_mock:
result = await generate_config("t1", task, build_dir)
assert result["VERSION"] == "2.100.0"
assert result["BUILD_VERSION"] == "2.100.0.0"
assert mock_config["versions"]["tracks"]["feature"]["build_map"] == {}
save_mock.assert_not_called()
async def test_generate_config_branch_track_override(tmp_path, log_streamer):
"""branch_track 中的显式配置优先于按分支名推断"""
build_dir = tmp_path / "build"
build_dir.mkdir()
task = MagicMock(app_id="1", scheme_id="1", build_type="Ad_Hoc",
obfuscation=False, branch="release/2.196")
mock_config = _versions_config(
{"app_ver": "2.196.0", "build_map": {}},
{"app_ver": "2.100.0", "build_map": {}},
)
mock_config["branch_track"] = {"release/2.196": "feature"}
with patch("backend.routers.config.load_config", return_value=mock_config):
result = await generate_config("t1", task, build_dir)
assert result["VERSION"] == "2.100.0"
async def test_generate_config_uses_server_theme_not_source_autopacking(tmp_path, log_streamer): async def test_generate_config_uses_server_theme_not_source_autopacking(tmp_path, log_streamer):
"""Web 打包不读取分支源码中的 AutoPacking 目录。""" """Web 打包不读取分支源码中的 AutoPacking 目录。"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"
@@ -416,3 +517,19 @@ def test_cleanup_keeps_recent(tmp_path):
assert recent_dir.exists() assert recent_dir.exists()
db.close() db.close()
def test_mark_superseded_tasks_for_same_oss_url():
from backend.database import SessionLocal, init_db
from backend.models import Task
init_db()
db = SessionLocal()
old = Task(id="old", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.html")
latest = Task(id="latest", app_id="1", app_name="测试App", build_type="Ad_Hoc", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.html")
db.add_all([old, latest])
db.commit()
_mark_superseded_tasks(db, latest)
assert db.get(Task, "old").superseded_at is not None
assert db.get(Task, "latest").superseded_at is None
db.close()
+91 -2
View File
@@ -3,13 +3,21 @@ import plistlib
from unittest.mock import patch from unittest.mock import patch
from backend.services.distribution import ( from backend.services.distribution import (
_artifact_stem,
delete_published_artifacts,
_write_distribution_files, _write_distribution_files,
_write_download_page, _write_download_page,
_write_manifest, _write_manifest,
publish_ipa, publish_ipa,
get_published_download_url,
) )
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): def test_distribution_files_use_current_service_config(tmp_path):
ipa = tmp_path / "source.ipa" ipa = tmp_path / "source.ipa"
ipa.write_bytes(b"ipa") ipa.write_bytes(b"ipa")
@@ -35,18 +43,99 @@ def test_app_store_distribution_uploads_only_ipa(tmp_path):
"APPID": "100", "APPID": "100",
"VERSION": "2.0.0", "VERSION": "2.0.0",
"BUILD_TYPE": "App_Store", "BUILD_TYPE": "App_Store",
"SOURCE_BRANCH": "main",
"OSS_FLODER": "readoor", "OSS_FLODER": "readoor",
"_upload_config": {"mode": "oss", "oss": {}}, "_upload_config": {"mode": "oss", "oss": {}},
} }
with patch( with patch(
"backend.services.distribution._upload_oss", "backend.services.distribution._upload_oss",
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0.ipa"}, return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0_main_appstore.ipa"},
) as upload: ) as upload:
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build") download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
uploaded_files = upload.call_args.args[1] uploaded_files = upload.call_args.args[1]
assert len(uploaded_files) == 1 assert len(uploaded_files) == 1
assert uploaded_files[0][0].suffix == ".ipa" assert uploaded_files[0][0].suffix == ".ipa"
assert download_url.endswith("100_2_0_0.ipa") assert download_url.endswith("100_2_0_0_main_appstore.ipa")
assert qr_path == "" 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",
]
def test_oss_download_url_is_short_lived_and_uses_actual_object_key(monkeypatch):
class FakeBucket:
def __init__(self, *_args, **_kwargs): pass
def sign_url(self, method, key, expires, **kwargs):
assert (method, key, expires, kwargs) == ("GET", "readoor/iOS/100_2_0_0_0.ipa", 600, {"slash_safe": True})
return "https://signed.example.com/app.ipa?signature=1"
class FakeOss:
Auth = staticmethod(lambda *_args: object())
Bucket = FakeBucket
monkeypatch.setitem(__import__("sys").modules, "oss2", FakeOss)
url = get_published_download_url(
{"BUILD_TYPE": "App_Store"},
{"mode": "oss", "oss": {"access_key_id": "id", "access_key_secret": "secret", "endpoint": "oss.example.com", "bucket_name": "bucket", "base_url": "https://files.example.com"}},
"https://files.example.com/readoor/iOS/100_2_0_0_0.ipa",
)
assert "signature=1" in url
+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")
+12 -2
View File
@@ -38,7 +38,7 @@ def test_production_rejects_default_security_settings(monkeypatch):
def test_config_routes_limit_regular_users_to_apps(client, tmp_config): def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
"""普通账号只能管理 Apps不能读取或修改包含密钥的配置""" """普通账号只能管理 Apps版本号等其余配置均为管理员专属"""
response = client.post("/api/users", json={ response = client.post("/api/users", json={
"username": "builder", "username": "builder",
"password": "builder-password-123", "password": "builder-password-123",
@@ -54,7 +54,17 @@ def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
assert client.get("/api/config/apps", headers=headers).status_code == 200 assert client.get("/api/config/apps", headers=headers).status_code == 200
assert client.post("/api/config/apps", json={"name": "普通用户 App"}, 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 == 403
assert client.put(
"/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers
).status_code == 403
assert client.put(
"/api/config/branches/track", json={"branch": "master", "track": "feature"}, headers=headers
).status_code == 403
assert client.get("/api/config", headers=headers).status_code == 403 assert client.get("/api/config", headers=headers).status_code == 403
assert client.get("/api/config/servers", 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 assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403