7 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
15 changed files with 923 additions and 597 deletions
+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 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
---
## 自动化测试 ## 自动化测试
### 运行测试 ### 运行测试
-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"
}
}
+211 -24
View File
@@ -26,10 +26,9 @@ 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/"):
or path == "/api/config/versions"):
return user return user
return require_admin(user) return require_admin(user)
@@ -90,10 +89,117 @@ DEFAULT_SERVERS = {
} }
} }
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 自动分配。 # 为已有环境迁移的固定前缀;后续环境从配置中的 next_app_id_prefix 自动分配。
LEGACY_SERVER_PREFIXES = { LEGACY_SERVER_PREFIXES = {
@@ -148,6 +254,14 @@ def _apply_special_app_prefix_override(app: dict) -> None:
app["app_id_prefix_override"] = 1 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: def _next_app_id(apps: dict, servers: dict, app: dict) -> str:
"""根据环境前缀或 App 特殊覆盖生成下一个配置 ID。""" """根据环境前缀或 App 特殊覆盖生成下一个配置 ID。"""
prefix = app.get("app_id_prefix_override") prefix = app.get("app_id_prefix_override")
@@ -247,13 +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。 # 自动补齐旧配置的环境前缀、特殊 App 覆盖和 upload_key。
prefixes_changed = _ensure_server_id_prefixes(config) prefixes_changed = _ensure_server_id_prefixes(config)
overrides_changed = _ensure_special_app_prefix_overrides(config) overrides_changed = _ensure_special_app_prefix_overrides(config)
keys_changed = _ensure_upload_keys(config) keys_changed = _ensure_upload_keys(config)
if prefixes_changed or overrides_changed or keys_changed: 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):
@@ -307,6 +420,7 @@ async def create_app(app: dict):
# 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。 # 按服务环境自动生成三位 ID(例如测试环境 1xx、正式环境 2xx)。
_apply_special_app_prefix_override(app) _apply_special_app_prefix_override(app)
_normalize_tencent_app_id(app)
new_id = _next_app_id(apps, config.get("servers", {}), app) new_id = _next_app_id(apps, config.get("servers", {}), app)
# 自动生成 upload_key # 自动生成 upload_key
@@ -329,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)
@@ -529,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"])
@@ -541,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:
@@ -555,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": "分支已删除"}
@@ -606,32 +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 重置为对应的 .0。""" """更新某条轨道的 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()
if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver): if not re.fullmatch(r"\d+\.\d+\.\d+", app_ver):
raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0") raise HTTPException(status_code=400, detail="App_Ver 必须为主版本.次版本.修订号,例如 2.196.0")
build_ver = f"{app_ver}.0" build_ver = data.get("build_ver")
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)
async with _config_lock: async with _config_lock:
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())
"build_ver": build_ver, 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
+32 -18
View File
@@ -601,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)
@@ -675,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:
+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/>
+71 -6
View File
@@ -24,7 +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' }, '/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({
@@ -168,19 +176,76 @@ describe('ConfigView.vue', () => {
expect(window.alert).toHaveBeenCalledWith('设置已保存') expect(window.alert).toHaveBeenCalledWith('设置已保存')
}) })
it('普通账号可修改版本号但看不到打包参数', async () => { it('普通账号只能管理 Apps,看不到打包设置与版本号', async () => {
const wrapper = mountConfigView({ admin: false }) const wrapper = mountConfigView({ admin: false })
await flushPromises() await flushPromises()
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text()) const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
expect(menuTexts).toEqual(['Apps 配置', '打包设置']) 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() === '打包设置') const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
await buildMenu.trigger('click') await buildMenu.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('应用版本号') const inputs = wrapper.findAll('input[type="text"]')
expect(wrapper.text()).not.toContain('打包参数') expect(inputs[0].element.value).toBe('2.195.0') // release App_Ver
expect(wrapper.findAll('input').length).toBe(1) 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' }])
}) })
}) })
+108 -18
View File
@@ -15,7 +15,7 @@
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li> <li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li> <li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li> <li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li> <li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li> <li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul> </ul>
</div> </div>
@@ -151,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>
@@ -160,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>
@@ -269,13 +279,36 @@
<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>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button> <div class="form-group">
<label>Build_Ver构建号</label>
<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_Ver 一致修改 App_Ver 会自动切换为该版本号已记录的构建号
每个版本号的构建号独立递增App_Store 打包时自动 +1一般无需手动修改
</div>
</div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('release')">保存上架版本号</button>
<h4 class="section-title">自测版本号feature 分支</h4>
<div class="form-group">
<label>App_Ver应用版本</label>
<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;">与上架版本号各自独立互不影响</div>
</div>
<div class="form-group">
<label>Build_Ver构建号</label>
<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>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions('feature')">保存自测版本号</button>
<template v-if="isAdmin"> <template v-if="isAdmin">
<h4 class="section-title">打包参数</h4> <h4 class="section-title">打包参数</h4>
@@ -458,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>
@@ -605,7 +638,32 @@ 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('{}')
@@ -636,12 +694,9 @@ onMounted(async () => {
const loadData = async () => { const loadData = async () => {
if (!isAdmin.value) { if (!isAdmin.value) {
const [appsRes, versionsRes] = await Promise.all([ // 普通用户只能管理 Apps,版本号等配置均为管理员专属
authFetch('/api/config/apps'), const appsRes = await authFetch('/api/config/apps')
authFetch('/api/config/versions'),
])
if (appsRes.ok) apps.value = await appsRes.json() if (appsRes.ok) apps.value = await appsRes.json()
if (versionsRes.ok) versions.value = await versionsRes.json()
return return
} }
@@ -663,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()
} }
@@ -959,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 => {
@@ -1088,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 {
@@ -1109,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({ app_ver: versions.value.app_ver }), 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()
+104 -1
View File
@@ -48,6 +48,31 @@ def test_create_app_rejects_unknown_environment_id_rule(client, tmp_config):
assert "ID 规则" in resp.json()["detail"] 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):
resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"}) resp = client.put("/api/config/apps/1", json={"name": "改名App", "AppGuid": "test-guid"})
assert resp.status_code == 200 assert resp.status_code == 200
@@ -172,7 +197,8 @@ def test_update_build_settings(client, tmp_config):
# ---- Versions ---- # ---- Versions ----
def test_update_versions_resets_build_number(client, tmp_config): 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"}) resp = client.put("/api/config/versions", json={"app_ver": "2.196.0"})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["app_ver"] == "2.196.0" assert resp.json()["app_ver"] == "2.196.0"
@@ -180,7 +206,84 @@ def test_update_versions_resets_build_number(client, tmp_config):
assert client.get("/api/config/versions").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): def test_update_versions_rejects_invalid_app_version(client, tmp_config):
resp = client.put("/api/config/versions", json={"app_ver": "2.196"}) resp = client.put("/api/config/versions", json={"app_ver": "2.196"})
assert resp.status_code == 400 assert resp.status_code == 400
assert "App_Ver" in resp.json()["detail"] 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()
+103 -5
View File
@@ -315,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"
@@ -325,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)
@@ -339,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"
+9 -6
View File
@@ -37,8 +37,8 @@ def test_production_rejects_default_security_settings(monkeypatch):
config.validate_production_security() config.validate_production_security()
def test_config_routes_limit_regular_users_to_apps_and_versions(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",
@@ -57,10 +57,13 @@ def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_conf
assert client.post("/api/config/apps", json={ assert client.post("/api/config/apps", json={
"name": "普通用户 App", "server": "测试环境", "name": "普通用户 App", "server": "测试环境",
}, headers=headers).status_code == 200 }, headers=headers).status_code == 200
assert client.get("/api/config/versions", headers=headers).status_code == 200 assert client.get("/api/config/versions", headers=headers).status_code == 403
versions = client.put("/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers) assert client.put(
assert versions.status_code == 200 "/api/config/versions", json={"app_ver": "2.196.0"}, headers=headers
assert versions.json()["build_ver"] == "2.196.0.0" ).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/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403